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,388 @@
1
+ """What literal strings a name or expression can hold (``getattr(x, NAME)``
2
+ with ``NAME`` from a literal table), and which names a scope binds or
3
+ mutates in place."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import ast
8
+ from collections.abc import Sequence
9
+
10
+ from diffcone.indexer.syntax import DEF_NODES, FUNC_NODES
11
+
12
+ # Synthetic key in a literal table: what *indexing* the literal bound to NAME
13
+ # yields -- a dict display's values, a sequence display's elements -- kept
14
+ # beside what iterating it yields (stored under NAME itself). No identifier
15
+ # contains a bracket, so the two can never collide.
16
+ INDEXED = "[]"
17
+
18
+
19
+ def _constant_string(expr: ast.expr) -> str | None:
20
+ """The string ``expr`` is, when it is written out in the source."""
21
+ if isinstance(expr, ast.Constant) and isinstance(expr.value, str):
22
+ return expr.value
23
+ if isinstance(expr, ast.JoinedStr):
24
+ parts = [v.value for v in expr.values if isinstance(v, ast.Constant)]
25
+ if len(parts) == len(expr.values):
26
+ return "".join(str(part) for part in parts)
27
+ return None
28
+
29
+
30
+ def _literal_strings(expr: ast.expr) -> tuple[str, ...] | None:
31
+ constant = _constant_string(expr)
32
+ if constant is not None:
33
+ return (constant,)
34
+ if isinstance(expr, ast.Dict):
35
+ # Iterating a dict yields its keys; only all-literal string keys count.
36
+ keys: list[str] = []
37
+ for key in expr.keys:
38
+ if key is None or (name := _constant_string(key)) is None:
39
+ return None
40
+ keys.append(name)
41
+ return tuple(keys)
42
+ if isinstance(expr, (ast.Tuple, ast.List, ast.Set)):
43
+ out: list[str] = []
44
+ for elt in expr.elts:
45
+ values = _literal_strings(elt)
46
+ if values is None:
47
+ return None
48
+ out.extend(values)
49
+ return tuple(out)
50
+ return None
51
+
52
+
53
+ def _string_candidates(
54
+ expr: ast.expr,
55
+ local_literals: dict[str, tuple[str, ...] | None],
56
+ module_literals: dict[str, tuple[str, ...] | None],
57
+ ) -> tuple[str, ...] | None:
58
+ direct = _literal_strings(expr)
59
+ if direct is not None:
60
+ return direct
61
+ if isinstance(expr, ast.Name):
62
+ if expr.id in local_literals:
63
+ return local_literals[expr.id]
64
+ return module_literals.get(expr.id)
65
+ # ``D.keys()`` over a literal-keyed dict yields its keys. (``D.items()``
66
+ # yields pairs and is handled only for ``for key, value in`` targets.)
67
+ if (receiver := _dict_method_receiver(expr, "keys")) is not None:
68
+ return _string_candidates(receiver, local_literals, module_literals)
69
+ # ``D.values()`` over a dict literal with string values yields them.
70
+ if (receiver := _dict_method_receiver(expr, "values")) is not None:
71
+ return _indexed(receiver, local_literals, module_literals)
72
+ # ``D[key]`` / ``L[i]``: one of the literal's values (a slice is not one).
73
+ if isinstance(expr, ast.Subscript) and not isinstance(expr.slice, ast.Slice):
74
+ return _indexed(expr.value, local_literals, module_literals)
75
+ return None
76
+
77
+
78
+ def _constant_strings(exprs: Sequence[ast.expr | None]) -> tuple[str, ...] | None:
79
+ """The strings ``exprs`` are, or None when any of them is not written out
80
+ as one (a ``*``/``**`` unpacking, whose element is None, included)."""
81
+ out: list[str] = []
82
+ for expr in exprs:
83
+ if expr is None or (string := _constant_string(expr)) is None:
84
+ return None
85
+ out.append(string)
86
+ return tuple(out)
87
+
88
+
89
+ def _indexed(
90
+ expr: ast.expr,
91
+ local_literals: dict[str, tuple[str, ...] | None],
92
+ module_literals: dict[str, tuple[str, ...] | None],
93
+ ) -> tuple[str, ...] | None:
94
+ """Every string indexing ``expr`` may yield, or None when unbounded: a
95
+ dict display's values, a sequence display's elements. A name is looked up
96
+ under its synthetic ``INDEXED`` key, which every binding of that name
97
+ writes, so a local shadowing a module-level table is unbounded rather
98
+ than that table's contents."""
99
+ if isinstance(expr, ast.Dict):
100
+ # ``{**other}`` has a None key and hides what it contributes.
101
+ if any(key is None for key in expr.keys):
102
+ return None
103
+ return _constant_strings(expr.values)
104
+ if isinstance(expr, (ast.Tuple, ast.List, ast.Set)):
105
+ return _constant_strings(expr.elts)
106
+ if isinstance(expr, ast.Name):
107
+ key = expr.id + INDEXED
108
+ if key in local_literals:
109
+ return local_literals[key]
110
+ return module_literals.get(key)
111
+ return None
112
+
113
+
114
+ def _dict_method_receiver(expr: ast.expr, method: str) -> ast.expr | None:
115
+ """``D`` when ``expr`` is ``D.<method>()`` with no arguments."""
116
+ if (
117
+ isinstance(expr, ast.Call)
118
+ and not expr.args
119
+ and not expr.keywords
120
+ and isinstance(expr.func, ast.Attribute)
121
+ and expr.func.attr == method
122
+ ):
123
+ return expr.func.value
124
+ return None
125
+
126
+
127
+ def _collect_literal_bindings(
128
+ node: ast.AST, module_literals: dict[str, tuple[str, ...] | None]
129
+ ) -> dict[str, tuple[str, ...] | None]:
130
+ """Names bound in ``node``'s scope to string literals, tuples of them, or
131
+ loop variables over such tuples. A name with any other binding maps to
132
+ None (unbounded); nested scopes are not entered. A name bound to a dict
133
+ or sequence display also gets what indexing it yields under
134
+ ``name + INDEXED``."""
135
+ found: dict[str, tuple[str, ...] | None] = {}
136
+
137
+ def merge(name: str, values: tuple[str, ...] | None) -> None:
138
+ known = found.get(name)
139
+ if known is not None and values is not None:
140
+ found[name] = tuple(dict.fromkeys(known + values))
141
+ else:
142
+ found[name] = None if (name in found and found[name] is None) else values
143
+
144
+ def bind(
145
+ name: str, values: tuple[str, ...] | None, indexed: tuple[str, ...] | None = None
146
+ ) -> None:
147
+ merge(name, values)
148
+ merge(name + INDEXED, indexed)
149
+
150
+ def unbind(name: str) -> None:
151
+ found[name] = found[name + INDEXED] = None
152
+
153
+ # Name stores the forms below bind; every other store of a name
154
+ # (``+=``, walrus, ``with ... as``, unpacking, ``except ... as``, an
155
+ # import, a match capture, ``global``/``nonlocal``) leaves it unbounded.
156
+ handled: set[int] = set()
157
+
158
+ # Source order matters: ``names = {...}`` must be seen before the loop
159
+ # that iterates it, so children are pushed reversed onto the LIFO stack.
160
+ stack: list[ast.AST] = list(reversed(list(ast.iter_child_nodes(node))))
161
+ while stack:
162
+ n = stack.pop()
163
+ if isinstance(n, NESTED_SCOPES):
164
+ # Not entered, but it may rebind names of this scope: ``nonlocal``
165
+ # or ``global`` declarations, a walrus inside a comprehension.
166
+ for inner in ast.walk(n):
167
+ if isinstance(inner, (ast.Global, ast.Nonlocal)):
168
+ for name in inner.names:
169
+ unbind(name)
170
+ elif isinstance(inner, ast.NamedExpr) and isinstance(inner.target, ast.Name):
171
+ unbind(inner.target.id)
172
+ continue
173
+ if isinstance(n, ast.Assign):
174
+ values = _string_candidates(n.value, found, module_literals)
175
+ items = _indexed(n.value, found, module_literals)
176
+ for target in n.targets:
177
+ if isinstance(target, ast.Name):
178
+ handled.add(id(target))
179
+ bind(target.id, values, items)
180
+ elif isinstance(n, ast.AnnAssign) and n.value is not None:
181
+ if isinstance(n.target, ast.Name):
182
+ handled.add(id(n.target))
183
+ bind(
184
+ n.target.id,
185
+ _string_candidates(n.value, found, module_literals),
186
+ _indexed(n.value, found, module_literals),
187
+ )
188
+ elif isinstance(n, (ast.For, ast.AsyncFor)) and isinstance(n.target, ast.Name):
189
+ handled.add(id(n.target))
190
+ bind(n.target.id, _string_candidates(n.iter, found, module_literals))
191
+ elif isinstance(n, (ast.For, ast.AsyncFor)) and isinstance(n.target, ast.Tuple):
192
+ # ``for key, value in D.items()``: over a dict display both the
193
+ # keys and the values are bounded, anything else is not.
194
+ elts = n.target.elts
195
+ keys = items = None
196
+ receiver = _dict_method_receiver(n.iter, "items")
197
+ if receiver is not None and len(elts) == 2:
198
+ keys = _string_candidates(receiver, found, module_literals)
199
+ items = _indexed(receiver, found, module_literals)
200
+ for i, elt in enumerate(elts):
201
+ if isinstance(elt, ast.Name):
202
+ handled.add(id(elt))
203
+ bind(elt.id, keys if i == 0 else items)
204
+ elif isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store):
205
+ if id(n) not in handled:
206
+ unbind(n.id)
207
+ elif isinstance(n, ast.Name) and isinstance(n.ctx, ast.Del):
208
+ # ``del name`` binds no new value (a later read fails): it only
209
+ # leaves a name that had no binding yet unbounded.
210
+ if n.id not in found:
211
+ unbind(n.id)
212
+ elif isinstance(n, ast.ExceptHandler) and n.name:
213
+ unbind(n.name)
214
+ elif isinstance(n, ast.alias):
215
+ unbind(n.asname or n.name.split(".")[0])
216
+ elif isinstance(n, (ast.MatchAs, ast.MatchStar)) and n.name:
217
+ unbind(n.name)
218
+ elif isinstance(n, ast.MatchMapping) and n.rest:
219
+ unbind(n.rest)
220
+ elif isinstance(n, (ast.Global, ast.Nonlocal)):
221
+ for name in n.names:
222
+ unbind(name)
223
+ elif (mutated := _mutated_name(n)) is not None:
224
+ # ``d[k] = v`` / ``d.append(x)``: the literal is not what it was.
225
+ unbind(mutated)
226
+ stack.extend(reversed(list(ast.iter_child_nodes(n))))
227
+ return found
228
+
229
+
230
+ COMPREHENSIONS = (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)
231
+
232
+
233
+ NESTED_SCOPES = DEF_NODES + COMPREHENSIONS + (ast.Lambda,)
234
+
235
+
236
+ # Methods that change a container in place (see also
237
+ # _ReferenceCollector.MUTATING_METHODS, which records writer edges).
238
+ _MUTATING_METHODS = frozenset(
239
+ {
240
+ "append",
241
+ "extend",
242
+ "insert",
243
+ "pop",
244
+ "popitem",
245
+ "remove",
246
+ "clear",
247
+ "update",
248
+ "setdefault",
249
+ "add",
250
+ "discard",
251
+ "sort",
252
+ "reverse",
253
+ "__setitem__",
254
+ "__delitem__",
255
+ }
256
+ )
257
+
258
+
259
+ def _mutated_name(node: ast.AST) -> str | None:
260
+ """The name a statement or call mutates in place (``d[k] = v``,
261
+ ``d.append(x)``, ``del d[k]``), if it is a plain name."""
262
+ targets: list[ast.expr] = []
263
+ if isinstance(node, ast.Assign):
264
+ targets = list(node.targets)
265
+ elif isinstance(node, (ast.AugAssign, ast.AnnAssign)):
266
+ targets = [node.target]
267
+ elif isinstance(node, ast.Delete):
268
+ targets = list(node.targets)
269
+ elif (
270
+ isinstance(node, ast.Call)
271
+ and isinstance(node.func, ast.Attribute)
272
+ and node.func.attr in _MUTATING_METHODS
273
+ ):
274
+ targets = [node.func.value]
275
+ for target in targets:
276
+ base = target
277
+ while isinstance(base, (ast.Subscript, ast.Attribute)):
278
+ base = base.value
279
+ if isinstance(base, ast.Name) and base is not target:
280
+ return base.id
281
+ if isinstance(base, ast.Name) and isinstance(node, ast.Call):
282
+ return base.id
283
+ return None
284
+
285
+
286
+ def _module_mutations(tree: ast.Module) -> set[str]:
287
+ """Every name the module mutates in place, anywhere in it (nested scopes
288
+ included): such a container's contents are not the literal it was
289
+ assigned."""
290
+ names: set[str] = set()
291
+ for node in ast.walk(tree):
292
+ mutated = _mutated_name(node)
293
+ if mutated is not None:
294
+ names.add(mutated)
295
+ return names
296
+
297
+
298
+ def _collect_store_names(stmt: ast.AST) -> set[str]:
299
+ """Names bound by a statement in *its own* scope (nested scopes excluded)."""
300
+ names: set[str] = set()
301
+ stack: list[ast.AST] = [stmt]
302
+ while stack:
303
+ node = stack.pop()
304
+ if isinstance(node, NESTED_SCOPES) and node is not stmt:
305
+ if isinstance(node, DEF_NODES):
306
+ names.add(node.name)
307
+ continue
308
+ if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)):
309
+ names.add(node.id)
310
+ elif isinstance(node, ast.ExceptHandler) and node.name:
311
+ names.add(node.name)
312
+ elif isinstance(node, (ast.MatchAs, ast.MatchStar)) and node.name:
313
+ names.add(node.name)
314
+ stack.extend(ast.iter_child_nodes(node))
315
+ return names
316
+
317
+
318
+ class _LocalBindings(ast.NodeVisitor):
319
+ """Collect the names a function, lambda or class body binds in its own
320
+ scope. Nested functions, lambdas and comprehensions get their own scope;
321
+ only their names (for defs) are bound here. ``global`` and ``nonlocal``
322
+ names are excluded.
323
+ """
324
+
325
+ def __init__(self) -> None:
326
+ self.names: set[str] = set()
327
+ self.globals: set[str] = set()
328
+
329
+ def collect(self, node: ast.AST) -> set[str]:
330
+ if isinstance(node, FUNC_NODES + (ast.Lambda,)):
331
+ self.visit(node.args)
332
+ body = getattr(node, "body", [])
333
+ for stmt in body if isinstance(body, list) else [body]:
334
+ self.visit(stmt)
335
+ return self.names - self.globals
336
+
337
+ def visit_arg(self, node: ast.arg) -> None:
338
+ self.names.add(node.arg)
339
+
340
+ def visit_Lambda(self, node: ast.Lambda) -> None:
341
+ return
342
+
343
+ def _visit_comprehension(self, node: ast.AST) -> None:
344
+ return
345
+
346
+ visit_ListComp = visit_SetComp = visit_DictComp = visit_GeneratorExp = _visit_comprehension
347
+
348
+ def visit_Name(self, node: ast.Name) -> None:
349
+ if isinstance(node.ctx, (ast.Store, ast.Del)):
350
+ self.names.add(node.id)
351
+
352
+ def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
353
+ if node.name:
354
+ self.names.add(node.name)
355
+ self.generic_visit(node)
356
+
357
+ def visit_MatchAs(self, node: ast.MatchAs) -> None:
358
+ if node.name:
359
+ self.names.add(node.name)
360
+ self.generic_visit(node)
361
+
362
+ def visit_MatchStar(self, node: ast.MatchStar) -> None:
363
+ if node.name:
364
+ self.names.add(node.name)
365
+
366
+ def visit_Global(self, node: ast.Global) -> None:
367
+ self.globals.update(node.names)
368
+
369
+ def visit_Nonlocal(self, node: ast.Nonlocal) -> None:
370
+ self.globals.update(node.names) # the enclosing function's, not local
371
+
372
+ def visit_Import(self, node: ast.Import) -> None:
373
+ for alias in node.names:
374
+ self.names.add(alias.asname or alias.name.split(".")[0])
375
+
376
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
377
+ for alias in node.names:
378
+ if alias.name != "*":
379
+ self.names.add(alias.asname or alias.name)
380
+
381
+ def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
382
+ self.names.add(node.name)
383
+
384
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
385
+ return self.visit_FunctionDef(node)
386
+
387
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
388
+ self.names.add(node.name)