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,441 @@
1
+ """Pass 1: a module's statements and definitions become symbols, with
2
+ their hashes, line spans and import tables."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import ast
7
+ from collections import defaultdict
8
+
9
+ from diffcone.indexer.definitions import (
10
+ _annotated_args,
11
+ _canonical_imports,
12
+ _end_line,
13
+ _flatten_chain,
14
+ _future_annotations,
15
+ _has_annotations,
16
+ _import_layout,
17
+ _inert_def,
18
+ _is_inert_decorator,
19
+ _is_literal,
20
+ _is_special_method,
21
+ _start_line,
22
+ _variable_statements,
23
+ )
24
+ from diffcone.indexer.literals import (
25
+ _collect_literal_bindings,
26
+ _collect_store_names,
27
+ _module_mutations,
28
+ )
29
+ from diffcone.indexer.scopes import (
30
+ ClassScope,
31
+ ImportBinding,
32
+ ModuleScope,
33
+ VariableStatement,
34
+ _absolute_module,
35
+ )
36
+ from diffcone.indexer.state import IndexerState
37
+ from diffcone.indexer.syntax import (
38
+ DEF_NODES,
39
+ FUNC_NODES,
40
+ _digest,
41
+ _docstring_hash,
42
+ _split_docstring,
43
+ hash_nodes,
44
+ hash_scope_body,
45
+ iter_scope_statements,
46
+ )
47
+ from diffcone.model import (
48
+ CLASS,
49
+ DEFINED_IN,
50
+ FUNCTION,
51
+ METHOD,
52
+ MODULE,
53
+ REFERENCES,
54
+ VARIABLE,
55
+ Edge,
56
+ Symbol,
57
+ )
58
+ from diffcone.snapshot import member_symbol_id
59
+
60
+
61
+ def _doc_reads(body: list[ast.stmt]):
62
+ """``__doc__`` names and attributes, and ``getdoc(...)`` calls, in a
63
+ scope's own statements (not in nested definitions, which are symbols
64
+ of their own)."""
65
+ stack: list[ast.AST] = list(body)
66
+ while stack:
67
+ node = stack.pop()
68
+ if isinstance(node, DEF_NODES):
69
+ # Decorators, defaults and bases run in this scope; the body not.
70
+ stack.extend(node.decorator_list)
71
+ if isinstance(node, ast.ClassDef):
72
+ stack.extend([*node.bases, *node.keywords])
73
+ else:
74
+ stack.extend([*node.args.defaults, *(d for d in node.args.kw_defaults if d)])
75
+ continue
76
+ # Reads only: ``__doc__ = """..."""`` assigns a docstring, it reads none.
77
+ if isinstance(node, ast.Name) and node.id == "__doc__":
78
+ if isinstance(node.ctx, ast.Load):
79
+ yield node
80
+ elif isinstance(node, ast.Attribute) and node.attr == "__doc__":
81
+ if isinstance(node.ctx, ast.Load):
82
+ yield node
83
+ elif isinstance(node, ast.Call):
84
+ func = node.func
85
+ name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", "")
86
+ if name == "getdoc":
87
+ yield node
88
+ stack.extend(ast.iter_child_nodes(node))
89
+
90
+
91
+ def _reads_docstrings_deep(node: ast.AST) -> bool:
92
+ """Like _reads_docstrings, nested definitions included."""
93
+ for inner in ast.walk(node):
94
+ if isinstance(inner, (ast.Name, ast.Attribute)) and isinstance(inner.ctx, ast.Load):
95
+ if getattr(inner, "id", None) == "__doc__" or getattr(inner, "attr", None) == "__doc__":
96
+ return True
97
+ if isinstance(inner, ast.Call):
98
+ func = inner.func
99
+ name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", "")
100
+ if name == "getdoc":
101
+ return True
102
+ return False
103
+
104
+
105
+ def _reads_docstrings(body: list[ast.stmt]) -> bool:
106
+ return next(_doc_reads(body), None) is not None
107
+
108
+
109
+ def _names_module_doc(body: list[ast.stmt]) -> bool:
110
+ return any(isinstance(n, ast.Name) for n in _doc_reads(body))
111
+
112
+
113
+ class FirstPass(IndexerState):
114
+ """Pass 1: modules and their definitions into symbols."""
115
+
116
+ def _module_statements(
117
+ self, scope: ModuleScope, *, register_imports: bool
118
+ ) -> tuple[list[ast.stmt], list[ast.stmt], dict[str, VariableStatement]]:
119
+ """(all scope statements, body without docstring, variable statements)
120
+ of a parsed module; records the import statements and, unless the
121
+ import table was served by the cache, fills it."""
122
+ assert scope.tree is not None
123
+ stmts = list(iter_scope_statements(scope.tree.body))
124
+ scope.import_nodes = [s for s in stmts if isinstance(s, (ast.Import, ast.ImportFrom))]
125
+ if register_imports:
126
+ for node in scope.import_nodes:
127
+ self._register_imports(
128
+ scope, node, scope.imports, scope.star_imports, scope.alt_imports
129
+ )
130
+ body = _split_docstring(scope.tree.body)[1]
131
+ return stmts, body, _variable_statements(scope, body)
132
+
133
+ def _index_module(self, scope: ModuleScope) -> None:
134
+ assert scope.tree is not None
135
+ stmts, body, variable_stmts = self._module_statements(scope, register_imports=True)
136
+ for stmt in stmts:
137
+ if not isinstance(stmt, DEF_NODES + (ast.Import, ast.ImportFrom)):
138
+ scope.bindings |= _collect_store_names(stmt)
139
+ scope.literal_names = _collect_literal_bindings(scope.tree, {})
140
+ scope.mutations = frozenset(_module_mutations(scope.tree))
141
+ imports = tuple(sorted(_canonical_imports(scope)))
142
+ layout = _import_layout(scope)
143
+ # A variable statement is its own symbol, so it leaves the module's
144
+ # body hash; one rebinding a def or class name (``helper = 3`` after
145
+ # ``def helper``) is not a variable symbol and stays in it.
146
+ defined = {s.name for s in stmts if isinstance(s, DEF_NODES)}
147
+ own_symbol = {id(stmt) for name, stmt in variable_stmts.items() if name not in defined}
148
+ module_body_hash = hash_scope_body(
149
+ [s for s in body if id(s) not in own_symbol], strip_imports=True
150
+ )
151
+ module_doc_hash = _docstring_hash([scope.tree.body])
152
+ module_reads_doc = _reads_docstrings(scope.tree.body)
153
+ if module_reads_doc and _names_module_doc(scope.tree.body):
154
+ # ``ArgumentParser(description=__doc__)``: import-time code reads
155
+ # the module's own docstring.
156
+ module_body_hash = _digest(module_body_hash + "|doc:" + module_doc_hash)
157
+ self._add_symbol(
158
+ Symbol(
159
+ id=scope.name,
160
+ kind=MODULE,
161
+ module=scope.name,
162
+ name=scope.name.rsplit(".", 1)[-1],
163
+ path=scope.path,
164
+ lineno=1,
165
+ body_hash=module_body_hash,
166
+ docstring_hash=module_doc_hash,
167
+ definition_hash=_digest("\n".join(imports) + "|layout|" + "\n".join(layout)),
168
+ container=None,
169
+ line_ranges=((1, _end_line(scope.tree)),),
170
+ imports=imports,
171
+ import_layout=layout,
172
+ reads_docstrings=module_reads_doc,
173
+ )
174
+ )
175
+ self._index_definitions(scope, scope.tree.body, scope.name, scope.members, None)
176
+ # Module-level statements that mention a variable may mutate it in place
177
+ # (``REGISTRY[k] = v``, ``NAMES.append(x)``, ``CONFIG.update(...)``), so
178
+ # they are part of that variable's body, not only of the module's.
179
+ mutators: dict[str, list[ast.stmt]] = defaultdict(list)
180
+ variable_ids = {id(s) for s in variable_stmts.values()}
181
+ for stmt in body:
182
+ if isinstance(stmt, DEF_NODES + (ast.Import, ast.ImportFrom)):
183
+ continue
184
+ if id(stmt) in variable_ids:
185
+ continue
186
+ mentioned = {
187
+ n.id for n in ast.walk(stmt) if isinstance(n, ast.Name) and n.id in variable_stmts
188
+ }
189
+ for name in mentioned:
190
+ mutators[name].append(stmt)
191
+ for name, stmt in variable_stmts.items():
192
+ if name in scope.members:
193
+ continue # also a def/class: kept in the module body hash above
194
+ symbol_id = self._member_id(scope.name, name)
195
+ value = stmt.value
196
+ assert value is not None # _variable_statements keeps assignments with a value
197
+ symbol = Symbol(
198
+ id=symbol_id,
199
+ kind=VARIABLE,
200
+ module=scope.name,
201
+ name=name,
202
+ path=scope.path,
203
+ lineno=stmt.lineno,
204
+ body_hash=hash_nodes([value, *mutators.get(name, [])]),
205
+ # An annotation is evaluated at import unless deferred: a
206
+ # change to it is a definition change (see classify).
207
+ annotation_hash=(
208
+ _digest(hash_nodes([stmt.annotation]))
209
+ if isinstance(stmt, ast.AnnAssign)
210
+ else ""
211
+ ),
212
+ deferred_annotations=_future_annotations(scope),
213
+ definition_hash="",
214
+ container=scope.name,
215
+ line_ranges=tuple(
216
+ (s.lineno, _end_line(s)) for s in (stmt, *mutators.get(name, []))
217
+ ),
218
+ # Binding a literal runs no code when the module is imported;
219
+ # only readers of the value can observe the change. ``__all__``
220
+ # is not inert: it decides what ``from m import *`` binds.
221
+ inert_definition=(
222
+ name != "__all__" and not mutators.get(name) and _is_literal(value)
223
+ ),
224
+ )
225
+ if self._add_symbol(symbol):
226
+ scope.variables[name] = symbol_id
227
+ scope.variable_stmts[name] = stmt
228
+ self.out.edges.add(Edge(symbol_id, scope.name, DEFINED_IN))
229
+
230
+ def _register_imports(
231
+ self,
232
+ scope: ModuleScope,
233
+ node: ast.stmt,
234
+ table: dict[str, ImportBinding],
235
+ stars: list[str],
236
+ alternatives: dict[str, list[ImportBinding]] | None = None,
237
+ ) -> None:
238
+ def bind(name: str, binding: ImportBinding) -> None:
239
+ previous = table.get(name)
240
+ if alternatives is not None and previous is not None and previous != binding:
241
+ if previous not in alternatives.setdefault(name, []):
242
+ alternatives[name].append(previous)
243
+ table[name] = binding
244
+
245
+ if isinstance(node, ast.Import):
246
+ for alias in node.names:
247
+ if alias.asname:
248
+ bind(alias.asname, ImportBinding(alias.name, None))
249
+ else:
250
+ bind(alias.name.split(".")[0], ImportBinding(alias.name.split(".")[0], None))
251
+ elif isinstance(node, ast.ImportFrom):
252
+ base = _absolute_module(scope, node.module, node.level)
253
+ for alias in node.names:
254
+ if alias.name == "*":
255
+ stars.append(base)
256
+ else:
257
+ bind(alias.asname or alias.name, ImportBinding(base, alias.name))
258
+
259
+ def _index_definitions(
260
+ self,
261
+ scope: ModuleScope,
262
+ body: list[ast.stmt],
263
+ container_id: str,
264
+ members: dict[str, str],
265
+ class_scope: ClassScope | None,
266
+ ) -> None:
267
+ funcs: dict[str, list[ast.FunctionDef | ast.AsyncFunctionDef]] = {}
268
+ classes: dict[str, list[ast.ClassDef]] = {}
269
+ order: list[str] = []
270
+ for stmt in iter_scope_statements(body):
271
+ if isinstance(stmt, FUNC_NODES):
272
+ funcs.setdefault(stmt.name, []).append(stmt)
273
+ elif isinstance(stmt, ast.ClassDef):
274
+ classes.setdefault(stmt.name, []).append(stmt)
275
+ else:
276
+ continue
277
+ if stmt.name not in order:
278
+ order.append(stmt.name)
279
+ for name in order:
280
+ if class_scope is None and container_id == scope.name:
281
+ symbol_id = self._member_id(scope.name, name)
282
+ else:
283
+ symbol_id = f"{container_id}.{name}"
284
+ if name in classes:
285
+ nodes = classes[name]
286
+ first = nodes[0]
287
+ member_names = sorted(
288
+ {
289
+ s.name
290
+ for n in nodes
291
+ for s in iter_scope_statements(n.body)
292
+ if isinstance(s, DEF_NODES)
293
+ }
294
+ )
295
+
296
+ def class_hashes(nodes=nodes, member_names=member_names):
297
+ definition_parts: list[ast.AST] = []
298
+ for n in nodes:
299
+ definition_parts += (
300
+ list(n.bases) + list(n.keywords) + list(n.decorator_list)
301
+ )
302
+ return (
303
+ _digest(
304
+ "\n".join(
305
+ hash_scope_body(_split_docstring(n.body)[1], strip_imports=False)
306
+ for n in nodes
307
+ )
308
+ ),
309
+ _digest(hash_nodes(definition_parts) + "|" + ",".join(member_names)),
310
+ _docstring_hash([n.body for n in nodes]),
311
+ )
312
+
313
+ body_hash, definition_hash, doc_hash = class_hashes()
314
+ symbol = Symbol(
315
+ id=symbol_id,
316
+ kind=CLASS,
317
+ module=scope.name,
318
+ name=name,
319
+ path=scope.path,
320
+ lineno=first.lineno,
321
+ body_hash=body_hash,
322
+ definition_hash=definition_hash,
323
+ container=container_id,
324
+ line_ranges=tuple((_start_line(n), _end_line(n)) for n in nodes),
325
+ docstring_hash=doc_hash,
326
+ reads_docstrings=any(_reads_docstrings(n.body) for n in nodes),
327
+ )
328
+ if not self._add_symbol(symbol):
329
+ continue
330
+ members[name] = symbol_id
331
+ self.out.edges.add(Edge(symbol_id, container_id, DEFINED_IN))
332
+ cscope = ClassScope(id=symbol_id, module=scope, enclosing=class_scope)
333
+ cscope.plain = len(nodes) == 1 and not (first.decorator_list or first.keywords)
334
+ for n in nodes:
335
+ cscope.base_chains.extend(_flatten_chain(b) for b in n.bases)
336
+ cscope.base_names.extend(
337
+ _flatten_chain(b.value if isinstance(b, ast.Subscript) else b)
338
+ for b in n.bases
339
+ )
340
+ self.class_scopes[symbol_id] = cscope
341
+ self._added_classes.append(cscope)
342
+ for n in nodes:
343
+ for stmt in iter_scope_statements(n.body):
344
+ if not isinstance(stmt, DEF_NODES):
345
+ cscope.bindings |= _collect_store_names(stmt)
346
+ # Every definition of the class (``if``/``else`` variants) is
347
+ # one symbol, so its members are indexed together: a method
348
+ # defined in several of them is one symbol too.
349
+ bodies = [stmt for n in nodes for stmt in n.body]
350
+ self._index_definitions(scope, bodies, symbol_id, cscope.members, cscope)
351
+ # Special methods run implicitly on instances (``==``, ``len()``,
352
+ # calling one, ``with``): whatever references the class may
353
+ # trigger them, so the class depends on them.
354
+ for member, member_id in sorted(cscope.members.items()):
355
+ member_symbol = self.index.symbols.get(member_id)
356
+ if (
357
+ _is_special_method(member)
358
+ and member_symbol is not None
359
+ and member_symbol.kind == METHOD
360
+ ):
361
+ self.out.edges.add(Edge(symbol_id, member_id, REFERENCES, "special_method"))
362
+ else:
363
+ nodes = funcs[name]
364
+ first = nodes[0]
365
+
366
+ def function_hashes(nodes=nodes):
367
+ definition_parts: list[ast.AST] = []
368
+ annotation_parts: list[ast.AST] = []
369
+ for n in nodes:
370
+ definition_parts.append(n.args)
371
+ definition_parts += list(n.decorator_list)
372
+ if n.returns is not None:
373
+ annotation_parts.append(n.returns)
374
+ # The definition hash excludes annotations: detach them
375
+ # while hashing (no copy of the tree) and restore them.
376
+ detached = [a for n in nodes for a in _annotated_args(n.args)]
377
+ annotations = [a.annotation for a in detached]
378
+ for a in detached:
379
+ a.annotation = None
380
+ try:
381
+ definition = hash_nodes(definition_parts)
382
+ finally:
383
+ for a, annotation in zip(detached, annotations, strict=True):
384
+ a.annotation = annotation
385
+ annotation_parts = [
386
+ *(a for a in annotations if a is not None),
387
+ *annotation_parts,
388
+ ]
389
+ return (
390
+ _digest(hash_nodes(annotation_parts)),
391
+ _digest(
392
+ "\n".join(hash_nodes(list(_split_docstring(n.body)[1])) for n in nodes)
393
+ ),
394
+ _digest(definition + "|" + ",".join(type(n).__name__ for n in nodes)),
395
+ _docstring_hash([n.body for n in nodes]),
396
+ )
397
+
398
+ annotation_hash, body_hash, definition_hash, doc_hash = function_hashes()
399
+ deferred = (
400
+ _future_annotations(scope)
401
+ and all(_is_inert_decorator(d, scope) for n in nodes for d in n.decorator_list)
402
+ and (class_scope is None or class_scope.plain)
403
+ )
404
+ header = all(_inert_def(n, scope) for n in nodes) and (
405
+ class_scope is None or class_scope.plain
406
+ )
407
+ inert = header and (deferred or not any(_has_annotations(n) for n in nodes))
408
+ symbol = Symbol(
409
+ id=symbol_id,
410
+ kind=METHOD if class_scope is not None else FUNCTION,
411
+ module=scope.name,
412
+ name=name,
413
+ path=scope.path,
414
+ lineno=first.lineno,
415
+ body_hash=body_hash,
416
+ docstring_hash=doc_hash,
417
+ definition_hash=definition_hash,
418
+ container=container_id,
419
+ line_ranges=tuple((_start_line(n), _end_line(n)) for n in nodes),
420
+ annotation_hash=annotation_hash,
421
+ deferred_annotations=deferred,
422
+ inert_definition=inert,
423
+ inert_header=header,
424
+ # Nested functions are not symbols: a decorator factory's
425
+ # wrapper reading ``f.__doc__`` counts for the factory.
426
+ reads_docstrings=any(_reads_docstrings_deep(n) for n in nodes),
427
+ )
428
+ if not self._add_symbol(symbol):
429
+ continue
430
+ members[name] = symbol_id
431
+ self.out.edges.add(Edge(symbol_id, container_id, DEFINED_IN))
432
+
433
+ def _member_id(self, module: str, name: str) -> str:
434
+ return member_symbol_id(module, name, self._children.get(module, frozenset()))
435
+
436
+ def modules_with_prefix(self, prefix: str) -> tuple[str, ...]:
437
+ return tuple(sorted(m for m in self.scopes if m.startswith(prefix)))
438
+
439
+ def symbol_names_with_prefix(self, prefix: str) -> tuple[str, ...]:
440
+ names = {s.name for s in self.index.symbols.values() if s.name.startswith(prefix)}
441
+ return tuple(sorted(names))
@@ -0,0 +1,173 @@
1
+ """AST helpers the indexer and discovery share: name tables, source
2
+ decoding, scope statements and the hashes of bodies and definitions."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import ast
7
+ import builtins
8
+ import copy
9
+ import hashlib
10
+ import io
11
+ import tokenize
12
+
13
+ BUILTIN_NAMES = frozenset(dir(builtins))
14
+
15
+
16
+ # Builtins whose argument nothing can bound: what they run is a string of
17
+ # code or the module's own namespace. ``__import__`` is not among them -- it
18
+ # names a module, and a name is exactly what the literal machinery bounds
19
+ # (pandas imports its hard dependencies with ``__import__`` in a loop over a
20
+ # literal tuple, and treating that as unbounded selected its whole suite).
21
+ DYNAMIC_CALLS = frozenset({"eval", "exec", "globals", "vars"})
22
+
23
+
24
+ # Reflection that observes names or signatures without naming them. Recorded
25
+ # for evidence mode only (SourceIndex.reflection); static planning ignores it.
26
+ REFLECTIVE_BUILTINS = frozenset({"dir", "hasattr", "vars"})
27
+
28
+
29
+ REFLECTIVE_CALLS = frozenset(
30
+ {
31
+ "inspect.getmembers",
32
+ "inspect.getmembers_static",
33
+ "inspect.signature",
34
+ "inspect.getfullargspec",
35
+ "inspect.getcallargs",
36
+ "inspect.get_annotations",
37
+ "typing.get_type_hints",
38
+ "annotationlib.get_annotations",
39
+ }
40
+ )
41
+
42
+
43
+ REFLECTIVE_ATTRIBUTES = frozenset(
44
+ {
45
+ "__dict__",
46
+ "__annotations__",
47
+ "__signature__",
48
+ "__code__",
49
+ "__defaults__",
50
+ "__kwdefaults__",
51
+ "modules", # sys.modules: any module, found by name
52
+ }
53
+ )
54
+
55
+
56
+ # How far to follow a module name passed from caller to caller before giving
57
+ # up and leaving the dynamic reference where it is.
58
+ IMPORT_ATTRIBUTION_DEPTH = 4
59
+
60
+
61
+ DEF_NODES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
62
+
63
+
64
+ FUNC_NODES = (ast.FunctionDef, ast.AsyncFunctionDef)
65
+
66
+
67
+ def decode_source(data: bytes) -> str:
68
+ """A source file as text, in the encoding it declares. Python reads a
69
+ coding cookie (PEP 263) or a BOM before it reads the source, and a file
70
+ that declares one is not UTF-8 (pip's latin-1 test package)."""
71
+ encoding, _ = tokenize.detect_encoding(io.BytesIO(data).readline)
72
+ return data.decode(encoding)
73
+
74
+
75
+ def _digest(text: str) -> str:
76
+ return hashlib.sha1(text.encode("utf-8")).hexdigest()[:16]
77
+
78
+
79
+ class _StripDefs(ast.NodeTransformer):
80
+ """Remove nested definitions (and optionally imports) from a scope body.
81
+
82
+ Definitions are hashed as their own symbols and imports as the module's
83
+ definition hash, so leaving them here would double-count changes.
84
+ """
85
+
86
+ def __init__(self, strip_imports: bool) -> None:
87
+ self.strip_imports = strip_imports
88
+
89
+ def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> ast.stmt | None:
90
+ return None
91
+
92
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> ast.stmt | None:
93
+ return self.visit_FunctionDef(node)
94
+
95
+ def visit_ClassDef(self, node: ast.ClassDef) -> ast.stmt | None:
96
+ return None
97
+
98
+ def visit_Import(self, node: ast.Import) -> ast.stmt | None:
99
+ return None if self.strip_imports else node
100
+
101
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> ast.stmt | None:
102
+ return None if self.strip_imports else node
103
+
104
+
105
+ def _dump(node: ast.AST) -> str:
106
+ return ast.dump(node, include_attributes=False)
107
+
108
+
109
+ def hash_nodes(nodes: list[ast.AST]) -> str:
110
+ return _digest("\n".join(_dump(n) for n in nodes))
111
+
112
+
113
+ def _contains_definition_or_import(stmt: ast.stmt, strip_imports: bool) -> bool:
114
+ for node in ast.walk(stmt):
115
+ if isinstance(node, DEF_NODES):
116
+ return True
117
+ if strip_imports and isinstance(node, (ast.Import, ast.ImportFrom)):
118
+ return True
119
+ return False
120
+
121
+
122
+ def _split_docstring(stmts: list[ast.stmt]) -> tuple[str, list[ast.stmt]]:
123
+ """(docstring text, statements without it) for a module/class/function body."""
124
+ if (
125
+ stmts
126
+ and isinstance(stmts[0], ast.Expr)
127
+ and isinstance(stmts[0].value, ast.Constant)
128
+ and isinstance(stmts[0].value.value, str)
129
+ ):
130
+ return stmts[0].value.value, stmts[1:]
131
+ return "", stmts
132
+
133
+
134
+ def _docstring_hash(bodies: list[list[ast.stmt]]) -> str:
135
+ docs = [_split_docstring(b)[0] for b in bodies]
136
+ return _digest("\n".join(docs)) if any(docs) else ""
137
+
138
+
139
+ def hash_scope_body(stmts: list[ast.stmt], strip_imports: bool) -> str:
140
+ """Hash a scope's statements with nested definitions (and optionally
141
+ imports) removed. Only statements that actually contain one are copied
142
+ and stripped; the rest are dumped as they are, which avoids a deep copy
143
+ of every statement (the dominant cost of indexing)."""
144
+ kept: list[ast.AST] = []
145
+ for stmt in stmts:
146
+ if isinstance(stmt, DEF_NODES) or (
147
+ strip_imports and isinstance(stmt, (ast.Import, ast.ImportFrom))
148
+ ):
149
+ continue
150
+ if _contains_definition_or_import(stmt, strip_imports):
151
+ stripped = _StripDefs(strip_imports).visit(copy.deepcopy(stmt))
152
+ if stripped is not None:
153
+ kept.append(stripped)
154
+ else:
155
+ kept.append(stmt)
156
+ return hash_nodes(kept)
157
+
158
+
159
+ def iter_scope_statements(body: list[ast.stmt]):
160
+ """Yield statements of a scope, descending into compound statements but
161
+ never into function or class definitions."""
162
+ for stmt in body:
163
+ yield stmt
164
+ if isinstance(stmt, DEF_NODES):
165
+ continue
166
+ for attr in ("body", "orelse", "finalbody"):
167
+ child = getattr(stmt, attr, None)
168
+ if isinstance(child, list):
169
+ yield from iter_scope_statements(child)
170
+ for handler in getattr(stmt, "handlers", []) or []:
171
+ yield from iter_scope_statements(handler.body)
172
+ for case in getattr(stmt, "cases", []) or []:
173
+ yield from iter_scope_statements(case.body)