codeterrain 0.3.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,2411 @@
1
+ """Static code index: definitions, imports, calls and patch targets of every Python file.
2
+
3
+ Every ``.py`` file under the root, application code and tests alike, is read with ``ast`` (the
4
+ target is never imported) and cached per file by content hash. ``terrain def`` finds where a name
5
+ is defined; ``terrain refs`` finds its call sites with their shape (positional and keyword
6
+ arguments), checks that shape against the signature, and lists the places that patch it.
7
+
8
+ Names are resolved the way a reader would: through the file's imports, its own module-level
9
+ definitions, ``self``/``cls`` inside a class, and re-exports (``from .orders import create`` in a
10
+ package ``__init__``). There is no type inference: a call on an object of unknown type
11
+ (``repo.save()``) is a by-name candidate, never an exact reference.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import ast
17
+ import functools
18
+ import json
19
+ import os
20
+ import re
21
+ import sqlite3
22
+ import stat
23
+ from collections.abc import Callable, Iterable, Iterator
24
+ from itertools import islice
25
+ from pathlib import Path, PurePosixPath
26
+ from typing import Any
27
+
28
+ from codeterrain.cache import (
29
+ ON_WINDOWS,
30
+ Cache,
31
+ _contains,
32
+ _create_private,
33
+ _private_dir,
34
+ user_cache_dir,
35
+ utc_now,
36
+ )
37
+ from codeterrain.config import Config
38
+ from codeterrain.fixtures_static import is_test_module
39
+
40
+ VIEW = "code"
41
+ #: The facts format: bumped whenever the facts read from a file change shape or meaning.
42
+ OBJECT_KIND = "code-22"
43
+ #: A file larger than this is reported, not parsed: ``ast`` on a huge generated module costs
44
+ #: memory out of proportion to what it tells.
45
+ MAX_FILE_BYTES = 2_000_000
46
+ _TOO_LARGE = f"file larger than {MAX_FILE_BYTES} bytes; not indexed"
47
+ _UNDECODABLE = "path is not valid UTF-8; not indexed"
48
+ #: Longest error text kept for a file: a parse error can quote the source (a long ``# coding:``
49
+ #: name), and every symlink alias of the file stores its own row.
50
+ MAX_ERROR_TEXT = 500
51
+ #: The parse is bounded by work, not only by bytes: a file of more tokens than this (ordinary code
52
+ #: runs to about one every four bytes, so a 2 MB module fits), or one that yields more facts of
53
+ #: any kind (definitions, imports, calls, values, patches) than ``MAX_FILE_FACTS``, is reported,
54
+ #: not indexed.
55
+ MAX_FILE_TOKENS = 600_000
56
+ MAX_FILE_FACTS = 50_000
57
+ #: How many bytes of strings a file's facts repeat at most: a fact carries its scope, class and
58
+ #: qualified name, which the source writes once, so a long function name over many calls would
59
+ #: otherwise keep its length once a call.
60
+ MAX_FILE_FACT_BYTES = 10_000_000
61
+ _TOKEN = re.compile(rb"\w+|[^\w\s]")
62
+
63
+
64
+ class _TooDense(Exception):
65
+ pass
66
+ #: How many re-export hops ``resolve`` follows before giving up.
67
+ MAX_HOPS = 10
68
+ #: How many names ``aliases`` collects for one query: renamed imports of a name number a few in
69
+ #: real code, so past this the search stops (and ``refs`` says its lists may be cut).
70
+ MAX_ALIASES = 1_000
71
+ #: How many values go into one ``IN (...)`` of a query: SQLite allows a few thousand parameters.
72
+ _BATCH = 500
73
+ #: How many definitions a bare or qualified name lists; a name shared by more is cut, with a
74
+ #: count, so a checkout with thousands of same-named methods cannot flood the output or memory.
75
+ MAX_MATCHES = 100
76
+ #: How many bytes of stored definitions those matches add up to at most: a few broad signatures
77
+ #: cost as much as thousands of plain ones, so the list is cut by size as well as by count.
78
+ MAX_MATCH_BYTES = 5_000_000
79
+ #: How many import rows ``importers`` and ``imports`` read at most.
80
+ MAX_IMPORTS = 10_000
81
+ #: The comprehensions that run where they are written (a generator expression runs later).
82
+ _EAGER = ("<listcomp>", "<setcomp>", "<dictcomp>")
83
+
84
+ #: ``made``: for a variable, the class it is an instance of as written (``settings: Settings =
85
+ #: ...`` or ``settings = Settings()``), else "": a constructor call or annotation, not resolved yet.
86
+ #: ``gen``: for a class, whether a decorator generates its ``__init__`` (``@dataclass``).
87
+ DEF_TYPES = {
88
+ "name": str, "kind": str, "line": "int", "col": "int", "end": "int", "endcol": "int",
89
+ "sig": str, "doc": str,
90
+ "pos": "strs", "posonly": "int", "kwonly": "strs", "kwreq": "strs", "defaults": "int",
91
+ "star": bool, "dstar": bool, "made": str, "gen": bool,
92
+ }
93
+ #: ``scope`` is "" for a module-level import; ``deep`` says whether nested functions see it (an
94
+ #: import in a function body does, one in a class body does not).
95
+ IMPORT_TYPES = {
96
+ "line": "int", "level": "int", "module": str, "name": str, "alias": str, "scope": str,
97
+ "deep": bool,
98
+ }
99
+ CALL_TYPES = {
100
+ "line": "int", "col": "int", "callee": str, "attr": str, "recv": str, "pos": "int",
101
+ "kw": "strs",
102
+ "star": bool, "dstar": bool, "scope": str, "cls": str, "local": bool, "glob": bool,
103
+ }
104
+ #: ``local``: an object target's receiver is a parameter or local, not the file's binding.
105
+ #: ``glob``: the receiver is declared ``global``, so it means the module's binding.
106
+ PATCH_TYPES = {
107
+ "line": "int", "target": str, "how": str, "scope": str, "local": bool, "glob": bool,
108
+ }
109
+ #: A base of a class (``class TestChild(Base)``), as written: ``cls`` is the class's qualified name.
110
+ BASE_TYPES = {"line": "int", "cls": str, "base": str}
111
+ #: A name used as a value, not called there. ``how`` says where: "arg" (passed to the call
112
+ #: ``within``: ``Depends(get_settings)``, a callback), "assign" (assigned as it is to ``within``),
113
+ #: "augment" (combined into ``within``: ``TOTAL += VALUE``), "member" (an element of a ``within``
114
+ #: literal: set, list, tuple or dict), "default" (a default of the function ``within``),
115
+ #: "return", "compare" or "read" (anywhere else: an ``if`` test, an operand, an f-string).
116
+ USE_TYPES = {
117
+ "line": "int", "col": "int", "name": str, "attr": str, "how": str, "within": str,
118
+ "scope": str, "cls": str, "glob": bool,
119
+ }
120
+
121
+
122
+ # --- reading one file --------------------------------------------------------------------------
123
+
124
+
125
+ def _dotted(node: ast.AST) -> str:
126
+ """``a.b.c`` for a chain of names and attributes, else ""."""
127
+ parts: list[str] = []
128
+ while isinstance(node, ast.Attribute):
129
+ parts.append(node.attr)
130
+ node = node.value
131
+ if not isinstance(node, ast.Name):
132
+ return ""
133
+ parts.append(node.id)
134
+ return ".".join(reversed(parts))
135
+
136
+
137
+ def _signature(args: ast.arguments) -> dict[str, Any]:
138
+ pos = [a.arg for a in (*args.posonlyargs, *args.args)]
139
+ kwonly = [a.arg for a in args.kwonlyargs]
140
+ kwreq = [a.arg for a, d in zip(args.kwonlyargs, args.kw_defaults, strict=True) if d is None]
141
+ defaults = len(args.defaults)
142
+ shown = [
143
+ name if i < len(pos) - defaults else f"{name}=…" for i, name in enumerate(pos)
144
+ ]
145
+ if args.posonlyargs:
146
+ shown.insert(len(args.posonlyargs), "/")
147
+ if args.vararg is not None:
148
+ shown.append(f"*{args.vararg.arg}")
149
+ elif kwonly:
150
+ shown.append("*")
151
+ shown.extend(
152
+ a.arg if d is None else f"{a.arg}=…"
153
+ for a, d in zip(args.kwonlyargs, args.kw_defaults, strict=True)
154
+ )
155
+ if args.kwarg is not None:
156
+ shown.append(f"**{args.kwarg.arg}")
157
+ return {
158
+ "sig": f"({', '.join(shown)})", "pos": pos, "posonly": len(args.posonlyargs),
159
+ "kwonly": kwonly, "kwreq": kwreq, "defaults": defaults, "star": args.vararg is not None,
160
+ "dstar": args.kwarg is not None,
161
+ }
162
+
163
+
164
+ _NO_SIGNATURE = {
165
+ "sig": "", "pos": [], "posonly": 0, "kwonly": [], "kwreq": [], "defaults": 0, "star": False,
166
+ "dstar": False,
167
+ }
168
+
169
+
170
+ def _doc(node: ast.AST) -> str:
171
+ try:
172
+ text = ast.get_docstring(node) or "" # type: ignore[arg-type]
173
+ except TypeError:
174
+ return ""
175
+ first = text.strip().splitlines()[0] if text.strip() else ""
176
+ return first[:120]
177
+
178
+
179
+ def _generates_init(decorators: list[ast.expr], aliases: set[str]) -> bool:
180
+ """Whether a class decorated so gets a generated ``__init__``: ``@dataclass`` (any module's,
181
+ or a name in ``aliases`` it was imported as), unless it says ``init=False``."""
182
+ for d in decorators:
183
+ func = d.func if isinstance(d, ast.Call) else d
184
+ dotted = _dotted(func)
185
+ if dotted.rsplit(".", 1)[-1] != "dataclass" and dotted not in aliases:
186
+ continue
187
+ init = next((k.value for k in d.keywords if k.arg == "init"), None) if isinstance(
188
+ d, ast.Call
189
+ ) else None
190
+ return not (isinstance(init, ast.Constant) and init.value is False)
191
+ return False
192
+
193
+
194
+ def _method_kind(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
195
+ names = {_dotted(d).rsplit(".", 1)[-1] for d in fn.decorator_list}
196
+ if "staticmethod" in names:
197
+ return "staticmethod"
198
+ if "classmethod" in names:
199
+ return "classmethod"
200
+ if "property" in names or "cached_property" in names:
201
+ return "property"
202
+ return "method"
203
+
204
+
205
+ def _argument(call: ast.Call, index: int, keyword: str) -> ast.expr | None:
206
+ """The argument passed at position ``index`` or by the name ``keyword``."""
207
+ positional = [a for a in call.args[: index + 1] if not isinstance(a, ast.Starred)]
208
+ if len(positional) == len(call.args[: index + 1]) and len(positional) > index:
209
+ return positional[index]
210
+ return next((k.value for k in call.keywords if k.arg == keyword), None)
211
+
212
+
213
+ def _string(node: ast.expr | None) -> str | None:
214
+ return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None
215
+
216
+
217
+ def _patch_target(call: ast.Call, callee: str) -> tuple[str, str] | None:
218
+ """(target, how) for ``patch("a.b")``, ``patch.object(X, "attr")`` and
219
+ ``monkeypatch.setattr(...)``, their arguments passed by position or by keyword, else None.
220
+ The reader passes a renamed import of ``patch`` here under its own name."""
221
+ tail = callee.rsplit(".", 1)[-1]
222
+ head = callee.rsplit(".", 1)[0] if "." in callee else ""
223
+ target = _argument(call, 0, "target")
224
+ if tail == "patch" and (named := _string(target)):
225
+ return named, "patch"
226
+ if tail == "object" and head.rsplit(".", 1)[-1] == "patch":
227
+ owner = _dotted(target) if target is not None else ""
228
+ attr = _string(_argument(call, 1, "attribute"))
229
+ if owner and attr:
230
+ return f"{owner}.{attr}", "patch.object"
231
+ if callee.endswith("monkeypatch.setattr"):
232
+ named = _string(target)
233
+ if named and "." in named:
234
+ return named, "monkeypatch.setattr"
235
+ attr = _string(_argument(call, 1, "name"))
236
+ owner = _dotted(target) if target is not None and named is None else ""
237
+ if owner and attr:
238
+ return f"{owner}.{attr}", "monkeypatch.setattr(obj)"
239
+ return None
240
+
241
+
242
+ #: Where ``patch`` is imported from: a renamed import of it is still a patch.
243
+ _PATCH_MODULES = ("unittest.mock", "mock")
244
+
245
+
246
+ _COMPREHENSIONS = (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)
247
+ _COMPREHENSION_NAMES = {
248
+ ast.ListComp: "<listcomp>", ast.SetComp: "<setcomp>", ast.DictComp: "<dictcomp>",
249
+ ast.GeneratorExp: "<genexpr>",
250
+ }
251
+
252
+
253
+ def _walrus_targets(node: ast.AST) -> set[str]:
254
+ """``(x := ...)`` inside a comprehension binds ``x`` in the enclosing function."""
255
+ found: set[str] = set()
256
+ stack = [node]
257
+ while stack:
258
+ sub = stack.pop()
259
+ if isinstance(sub, ast.NamedExpr) and isinstance(sub.target, ast.Name):
260
+ found.add(sub.target.id)
261
+ if not isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)):
262
+ stack.extend(ast.iter_child_nodes(sub))
263
+ return found
264
+
265
+
266
+ def _bound_names(
267
+ node: ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda,
268
+ ) -> tuple[set[str], set[str], set[str]]:
269
+ """The names bound in the function's own scope: (parameters and assignments, imports, names
270
+ declared ``global``, which mean the module's binding whatever encloses the function). A
271
+ nested function, lambda, class or comprehension keeps its bindings to itself, and each node
272
+ is visited once here, so nesting costs no repeated walks; a name declared ``global`` or
273
+ ``nonlocal`` is not local."""
274
+ args = node.args
275
+ names = {
276
+ a.arg for a in [*args.posonlyargs, *args.args, *args.kwonlyargs, args.vararg, args.kwarg]
277
+ if a is not None
278
+ }
279
+ declared: set[str] = set()
280
+ imported: set[str] = set()
281
+ globals_: set[str] = set()
282
+ # What each imported name was imported from: a second, different source makes it ambiguous.
283
+ sources: dict[str, tuple[int, str, str]] = {}
284
+ stack: list[ast.AST] = list(node.body) if isinstance(node.body, list) else [node.body]
285
+ while stack:
286
+ sub = stack.pop()
287
+ if isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
288
+ # Only the name, the decorators and the defaults belong to this scope.
289
+ if not isinstance(sub, ast.Lambda):
290
+ names.add(sub.name)
291
+ stack.extend(sub.decorator_list)
292
+ stack.extend([*sub.args.defaults, *(d for d in sub.args.kw_defaults if d)])
293
+ continue
294
+ if isinstance(sub, ast.ClassDef):
295
+ names.add(sub.name)
296
+ stack.extend([*sub.decorator_list, *sub.bases, *(k.value for k in sub.keywords)])
297
+ continue
298
+ if isinstance(sub, _COMPREHENSIONS):
299
+ stack.append(sub.generators[0].iter) # the one part evaluated in this scope
300
+ names |= _walrus_targets(sub)
301
+ continue
302
+ if isinstance(sub, (ast.Import, ast.ImportFrom)):
303
+ for a in sub.names:
304
+ if a.name == "*":
305
+ continue
306
+ if isinstance(sub, ast.Import):
307
+ local = a.asname or a.name.split(".", 1)[0]
308
+ source = (0, a.name if a.asname else local, "")
309
+ else:
310
+ local = a.asname or a.name
311
+ source = (sub.level, sub.module or "", a.name)
312
+ imported.add(local)
313
+ if sources.setdefault(local, source) != source:
314
+ names.add(local)
315
+ continue
316
+ if isinstance(sub, ast.Name) and isinstance(sub.ctx, ast.Store):
317
+ names.add(sub.id)
318
+ elif isinstance(sub, ast.ExceptHandler) and sub.name:
319
+ names.add(sub.name)
320
+ elif isinstance(sub, (ast.MatchAs, ast.MatchStar)) and sub.name:
321
+ names.add(sub.name) # `case {"callback": create}:` captures `create`
322
+ elif isinstance(sub, ast.MatchMapping) and sub.rest:
323
+ names.add(sub.rest)
324
+ elif isinstance(sub, (ast.Global, ast.Nonlocal)):
325
+ declared.update(sub.names)
326
+ if isinstance(sub, ast.Global):
327
+ globals_.update(sub.names)
328
+ stack.extend(ast.iter_child_nodes(sub))
329
+ # A name the function both imports and assigns (`from lib import create`, then
330
+ # `create = callback`), or imports from two places, means whichever ran last, which a static
331
+ # reader cannot tell: it is left a local, unresolved, rather than claimed for an import.
332
+ return names - declared, imported - declared - names, globals_
333
+
334
+
335
+ class _Reader(ast.NodeVisitor):
336
+ def __init__(self) -> None:
337
+ self.defs: list[dict[str, Any]] = []
338
+ self.imports: list[dict[str, Any]] = []
339
+ # The names `dataclass` was imported as (`from dataclasses import dataclass as dc`).
340
+ self._dataclass_names: set[str] = set()
341
+ self.calls: list[dict[str, Any]] = []
342
+ self.patches: list[dict[str, Any]] = []
343
+ self.uses: list[dict[str, Any]] = []
344
+ self.bases: list[dict[str, Any]] = []
345
+ self._spent = 0
346
+ self._bytes = 0
347
+ self._scope: list[str] = []
348
+ self._classes: list[str] = []
349
+ self._in_function = 0
350
+ # Names bound inside each enclosing function: passing one is not a reference to a def.
351
+ # Each entry is (names bound there, names imported there, names declared global there);
352
+ # the innermost scope that has the name decides.
353
+ self._locals: list[tuple[set[str], set[str], set[str]]] = []
354
+ # Names `patch` is imported under (`from unittest.mock import patch as mock_patch`),
355
+ # and what each (scope, name) is imported as there: True for `patch`, False for anything
356
+ # else or for both, with whether the scope is a class body.
357
+ self._patchers: set[str] = set()
358
+ self._imported: dict[tuple[str, str], tuple[bool, bool]] = {}
359
+ # The name and attribute nodes already kept (as a call, an argument or a use of a known
360
+ # kind): the walk keeps every other read as a plain "read" use.
361
+ self._seen: set[int] = set()
362
+
363
+ def _qualname(self, name: str) -> str:
364
+ return ".".join([*self._scope, name])
365
+
366
+ def _spend(self, facts: int, size: int = 0) -> None:
367
+ """Count ``facts`` about to be kept, carrying ``size`` bytes of repeated strings (scope,
368
+ class, qualified name), against the file's budget, before they are kept: every kind
369
+ counts, so no statement shape (a chain of assignments, a call of many arguments, a long
370
+ import list, a long name over many calls) can keep more than ``MAX_FILE_FACTS`` facts or
371
+ ``MAX_FILE_FACT_BYTES`` bytes."""
372
+ self._spent += facts
373
+ self._bytes += size
374
+ if self._spent > MAX_FILE_FACTS:
375
+ raise _TooDense(f"more than {MAX_FILE_FACTS} facts; not indexed")
376
+ if self._bytes > MAX_FILE_FACT_BYTES:
377
+ raise _TooDense(f"more than {MAX_FILE_FACT_BYTES} bytes of facts; not indexed")
378
+
379
+ def _add_def(
380
+ self, node: ast.AST, name: str, kind: str, signature: dict[str, Any], made: str = "",
381
+ gen: bool = False, until: ast.AST | None = None,
382
+ ) -> None:
383
+ # ``until``: the node whose end is the moment the name is bound, when that is not the
384
+ # end of ``node`` (`for T in ITER` binds T once ITER is evaluated). A read before that
385
+ # position, on the same line too, sees the earlier binding; one after it, this one.
386
+ last = until or node
387
+ self._spend(1, len(self._qualname(name)) + len(made))
388
+ self.defs.append(
389
+ {
390
+ "name": self._qualname(name), "kind": kind, "line": node.lineno, # type: ignore[attr-defined]
391
+ "col": getattr(node, "col_offset", None) or 0,
392
+ "end": getattr(last, "end_lineno", None) or node.lineno, # type: ignore[attr-defined]
393
+ "endcol": getattr(last, "end_col_offset", None) or 0,
394
+ "doc": _doc(node) if kind != "variable" else "", **signature, "made": made,
395
+ "gen": gen,
396
+ }
397
+ )
398
+
399
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
400
+ for d in node.decorator_list:
401
+ self.visit(d)
402
+ for base in [*node.bases, *(k.value for k in node.keywords)]:
403
+ self.visit(base)
404
+ if not self._in_function:
405
+ self._add_def(
406
+ node, node.name, "class", _NO_SIGNATURE,
407
+ gen=_generates_init(node.decorator_list, self._dataclass_names),
408
+ )
409
+ qualname = self._qualname(node.name)
410
+ for base in node.bases:
411
+ if dotted := _dotted(base):
412
+ self._spend(1, len(dotted))
413
+ self.bases.append({"line": node.lineno, "cls": qualname, "base": dotted})
414
+ self._scope.append(node.name)
415
+ self._classes.append(".".join(self._scope))
416
+ for stmt in node.body:
417
+ self.visit(stmt)
418
+ self._classes.pop()
419
+ self._scope.pop()
420
+
421
+ def _function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
422
+ for d in node.decorator_list:
423
+ self.visit(d)
424
+ for default in [*node.args.defaults, *(d for d in node.args.kw_defaults if d)]:
425
+ self._value(default, "default", node.name)
426
+ self.visit(default)
427
+ # `user: Annotated[str, Depends(get_user)]` names a dependency in an annotation.
428
+ args = node.args
429
+ for a in [*args.posonlyargs, *args.args, *args.kwonlyargs, args.vararg, args.kwarg]:
430
+ if a is not None and a.annotation is not None:
431
+ self.visit(a.annotation)
432
+ if node.returns is not None:
433
+ self.visit(node.returns)
434
+ if not self._in_function:
435
+ in_class = bool(self._classes) and self._scope and ".".join(self._scope) == (
436
+ self._classes[-1]
437
+ )
438
+ kind = _method_kind(node) if in_class else "function"
439
+ # Every parameter is kept (in the signature and its lists): counted before it is.
440
+ args = node.args
441
+ self._spend(
442
+ len(args.posonlyargs) + len(args.args) + len(args.kwonlyargs)
443
+ + (args.vararg is not None) + (args.kwarg is not None)
444
+ )
445
+ self._add_def(node, node.name, kind, _signature(args))
446
+ self._scope.append(node.name)
447
+ self._in_function += 1
448
+ self._locals.append(_bound_names(node))
449
+ for stmt in node.body:
450
+ self.visit(stmt)
451
+ self._locals.pop()
452
+ self._in_function -= 1
453
+ self._scope.pop()
454
+
455
+ visit_FunctionDef = _function
456
+ visit_AsyncFunctionDef = _function
457
+
458
+ def _assigned(
459
+ self, node: ast.AST, targets: list[ast.expr], made: str = "",
460
+ until: ast.AST | None = None,
461
+ ) -> None:
462
+ if self._in_function:
463
+ return
464
+ # `a, (b, *c) = ...` binds every name in the target, as `a = ...` binds one. A stack,
465
+ # pushed in reverse, keeps source order and costs one step a name. Only a name assigned
466
+ # directly (`a = b = Settings()`, not `a, b = ...`) is an instance of ``made``.
467
+ pending = targets[::-1]
468
+ while pending:
469
+ target = pending.pop()
470
+ if isinstance(target, ast.Name):
471
+ self._add_def(
472
+ node, target.id, "variable", _NO_SIGNATURE,
473
+ made if any(t is target for t in targets) else "", until=until,
474
+ )
475
+ elif isinstance(target, (ast.Tuple, ast.List)):
476
+ pending.extend(reversed(target.elts))
477
+ elif isinstance(target, ast.Starred):
478
+ pending.append(target.value)
479
+
480
+ def visit_Assign(self, node: ast.Assign) -> None:
481
+ made = _dotted(node.value.func) if isinstance(node.value, ast.Call) else ""
482
+ self._assigned(node, node.targets, made)
483
+ # `A = B = VALUE` assigns the value to each target: one use for each.
484
+ for target in node.targets:
485
+ self._value(node.value, "assign", _dotted(target))
486
+ self.generic_visit(node)
487
+
488
+ def visit_AugAssign(self, node: ast.AugAssign) -> None:
489
+ # `TOTAL += VALUE` reads the value into a computed one: not another name for it. It
490
+ # also rebinds TOTAL, so an earlier `TOTAL = VALUE` or `TOTAL = Settings()` is no longer
491
+ # what TOTAL holds.
492
+ self._assigned(node, [node.target])
493
+ self._value(node.value, "augment", _dotted(node.target))
494
+ self.generic_visit(node)
495
+
496
+ def visit_For(self, node: ast.For | ast.AsyncFor) -> None:
497
+ # The target binds before the body runs, once the iterable is evaluated: a read in the
498
+ # iterable (`for A in [A]`) sees the binding from before.
499
+ self._assigned(node.target, [node.target], until=node.iter)
500
+ self.generic_visit(node)
501
+
502
+ visit_AsyncFor = visit_For
503
+
504
+ def visit_NamedExpr(self, node: ast.NamedExpr) -> None:
505
+ # `(create := ...)` outside a function binds in the module or class body, from the
506
+ # target's line: the value runs first. In a comprehension it binds in the scope around
507
+ # it (Python's rule), so the comprehension segments are left out of its name.
508
+ self.visit(node.value)
509
+ if not self._in_function:
510
+ scope = self._scope
511
+ while self._scope and self._scope[-1] in _COMPREHENSION_NAMES.values():
512
+ self._scope = self._scope[:-1]
513
+ self._assigned(node.target, [node.target], until=node.value)
514
+ self._scope = scope
515
+
516
+ def visit_With(self, node: ast.With | ast.AsyncWith) -> None:
517
+ for item in node.items:
518
+ if item.optional_vars is not None:
519
+ self._assigned(item.optional_vars, [item.optional_vars])
520
+ self.generic_visit(node)
521
+
522
+ visit_AsyncWith = visit_With
523
+
524
+ def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
525
+ if node.value is not None:
526
+ # `settings: Settings = get_settings()`: the annotation names the class; without
527
+ # one that is a plain name, the constructor call does.
528
+ made = _dotted(node.annotation) or (
529
+ _dotted(node.value.func) if isinstance(node.value, ast.Call) else ""
530
+ )
531
+ self._assigned(node, [node.target], made)
532
+ self._value(node.value, "assign", _dotted(node.target))
533
+ self.generic_visit(node)
534
+
535
+ def visit_Return(self, node: ast.Return) -> None:
536
+ self._value(node.value, "return", "")
537
+ self.generic_visit(node)
538
+
539
+ def visit_Yield(self, node: ast.Yield | ast.YieldFrom) -> None:
540
+ self._value(node.value, "return", "")
541
+ self.generic_visit(node)
542
+
543
+ visit_YieldFrom = visit_Yield
544
+
545
+ def visit_Compare(self, node: ast.Compare) -> None:
546
+ for operand in [node.left, *node.comparators]:
547
+ self._value(operand, "compare", "")
548
+ self.generic_visit(node)
549
+
550
+ def _members(self, node: ast.Set | ast.List | ast.Tuple | ast.Dict, kind: str) -> None:
551
+ if not isinstance(node, ast.Set | ast.Dict) and not isinstance(node.ctx, ast.Load):
552
+ return self.generic_visit(node) # an assignment target, not a value
553
+ elements = (
554
+ [*(k for k in node.keys if k is not None), *node.values]
555
+ if isinstance(node, ast.Dict) else node.elts
556
+ )
557
+ for element in elements:
558
+ self._value(
559
+ element.value if isinstance(element, ast.Starred) else element, "member", kind
560
+ )
561
+ self.generic_visit(node)
562
+
563
+ def visit_Set(self, node: ast.Set) -> None:
564
+ self._members(node, "set")
565
+
566
+ def visit_List(self, node: ast.List) -> None:
567
+ self._members(node, "list")
568
+
569
+ def visit_Tuple(self, node: ast.Tuple) -> None:
570
+ self._members(node, "tuple")
571
+
572
+ def visit_Dict(self, node: ast.Dict) -> None:
573
+ self._members(node, "dict")
574
+
575
+ def visit_Name(self, node: ast.Name) -> None:
576
+ if isinstance(node.ctx, ast.Load) and id(node) not in self._seen:
577
+ self._value(node, "read", "")
578
+
579
+ def visit_Attribute(self, node: ast.Attribute) -> None:
580
+ # `settings.TABLE` read anywhere not already kept with a kind (an `if` test, an operand,
581
+ # a loop's iterable): one use of the whole chain, not of each part.
582
+ if not _dotted(node):
583
+ self.generic_visit(node) # `make().TABLE`: the call inside is still read
584
+ elif isinstance(node.ctx, ast.Load) and id(node) not in self._seen:
585
+ self._value(node, "read", "")
586
+
587
+ def _value(self, node: ast.expr | None, how: str, within: str) -> None:
588
+ """Keep ``node`` as a use of the name it reads, when it is a name or attribute chain
589
+ (``settings.TABLE``) that no enclosing function binds: a parameter or local is not a
590
+ reference to a definition. The node is marked, so the walk does not keep it again as a
591
+ plain read."""
592
+ name = _dotted(node) if isinstance(node, (ast.Name, ast.Attribute)) else ""
593
+ if not name:
594
+ return
595
+ self._seen.add(id(node))
596
+ if self._binder(name) == "local":
597
+ return
598
+ scope = ".".join(self._scope)
599
+ cls = self._classes[-1] if self._classes else ""
600
+ self._spend(1, len(scope) + len(cls) + len(name) + len(within))
601
+ self.uses.append(
602
+ {
603
+ "line": node.lineno, "col": node.col_offset, # type: ignore[attr-defined]
604
+ "name": name, "attr": name.rsplit(".", 1)[-1], # type: ignore[union-attr]
605
+ "how": how, "within": within, "scope": scope, "cls": cls,
606
+ "glob": self._binder(name) == "global",
607
+ }
608
+ )
609
+
610
+ def _import_scope(self) -> dict[str, Any]:
611
+ scope = ".".join(self._scope)
612
+ in_class_body = bool(self._classes) and scope == self._classes[-1]
613
+ return {"scope": scope, "deep": not in_class_body}
614
+
615
+ def _record_import(self, local: str, is_patch: bool) -> None:
616
+ scope = ".".join(self._scope)
617
+ key = (scope, local)
618
+ earlier = self._imported.get(key)
619
+ in_class_body = bool(self._classes) and scope == self._classes[-1]
620
+ self._imported[key] = (is_patch and (earlier is None or earlier[0]), in_class_body)
621
+ if is_patch:
622
+ self._patchers.add(local)
623
+
624
+ def _patch_import(self, name: str) -> bool | None:
625
+ """Whether ``name`` here means an import of ``patch``, under its own name or another:
626
+ the innermost scope that binds it decides, a parameter or local is not, and a class
627
+ body's import counts only in that body; a scope that imports the name both as ``patch``
628
+ and as something else leaves it unknown, so not a patch. None when no import or local
629
+ binds the name (a star import, say): then only its spelling is known."""
630
+ binder = self._binder(name)
631
+ if binder == "local":
632
+ return False
633
+ if binder == "global":
634
+ found = self._imported.get(("", name))
635
+ return None if found is None else found[0]
636
+ for i in range(len(self._scope), -1, -1):
637
+ found = self._imported.get((".".join(self._scope[:i]), name))
638
+ if found is not None and (i == len(self._scope) or not found[1]):
639
+ return found[0]
640
+ return None
641
+
642
+ def visit_Import(self, node: ast.Import) -> None:
643
+ for alias in node.names:
644
+ # `import a.b` binds `a`; `import a.b as x` binds `x` to `a.b`.
645
+ local = alias.asname or alias.name.split(".", 1)[0]
646
+ module = alias.name if alias.asname else alias.name.split(".", 1)[0]
647
+ self._record_import(local, False)
648
+ self._spend(1, len(".".join(self._scope)) + len(module))
649
+ self.imports.append(
650
+ {
651
+ "line": node.lineno, "level": 0, "module": module, "name": "",
652
+ "alias": local, **self._import_scope(),
653
+ }
654
+ )
655
+
656
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
657
+ for alias in node.names:
658
+ # `from m import *` binds names known only at run time, but the dependency on `m` is
659
+ # plain: it is kept as an import of `m.*` bound to `*`, which no name ever looks up.
660
+ if alias.name != "*":
661
+ self._record_import(
662
+ alias.asname or alias.name,
663
+ not node.level and node.module in _PATCH_MODULES and alias.name == "patch",
664
+ )
665
+ self._spend(1, len(".".join(self._scope)) + len(node.module or ""))
666
+ if alias.name == "dataclass":
667
+ self._dataclass_names.add(alias.asname or alias.name)
668
+ self.imports.append(
669
+ {
670
+ "line": node.lineno, "level": node.level, "module": node.module or "",
671
+ "name": alias.name, "alias": alias.asname or alias.name,
672
+ **self._import_scope(),
673
+ }
674
+ )
675
+
676
+ def visit_Call(self, node: ast.Call) -> None:
677
+ callee = _dotted(node.func)
678
+ attr = node.func.attr if isinstance(node.func, ast.Attribute) else (
679
+ node.func.id if isinstance(node.func, ast.Name) else ""
680
+ )
681
+ scope = ".".join(self._scope)
682
+ # The call and each of its arguments (a value use, a keyword name) count before any is
683
+ # kept, so one call of a hundred thousand arguments is refused as a dense file is; each
684
+ # repeats the scope and class, and a value use the callee too.
685
+ args = len(node.args) + len(node.keywords)
686
+ context = len(scope) + len(self._classes[-1] if self._classes else "")
687
+ self._spend(1 + args, (1 + args) * context + args * len(callee))
688
+ # `Service(repo).place(...)`: the receiver is a call, often a constructor.
689
+ recv = (
690
+ _dotted(node.func.value.func)
691
+ if isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Call)
692
+ else ""
693
+ )
694
+ # `def invoke(create): create(1)`: a name bound in the function hides the import.
695
+ binder = self._binder(callee or recv) if callee or recv else ""
696
+ self.calls.append(
697
+ {
698
+ "line": node.lineno, "col": node.col_offset, "callee": callee, "attr": attr,
699
+ "recv": recv,
700
+ "pos": sum(1 for a in node.args if not isinstance(a, ast.Starred)),
701
+ "kw": [k.arg for k in node.keywords if k.arg is not None],
702
+ "star": any(isinstance(a, ast.Starred) for a in node.args),
703
+ "dstar": any(k.arg is None for k in node.keywords),
704
+ "scope": scope, "cls": self._classes[-1] if self._classes else "",
705
+ "local": binder == "local", "glob": binder == "global",
706
+ }
707
+ )
708
+ # The callee is kept as the call, not also as a read; a receiver that is not a plain chain
709
+ # (`make().save()`) is still walked.
710
+ if callee:
711
+ self._seen.add(id(node.func))
712
+ for value in [*node.args, *(k.value for k in node.keywords)]:
713
+ name = _dotted(value) if isinstance(value, (ast.Name, ast.Attribute)) else ""
714
+ if name:
715
+ self._seen.add(id(value))
716
+ binder = self._binder(name) if name else "local"
717
+ if binder != "local":
718
+ self.uses.append(
719
+ {
720
+ "line": value.lineno, "col": value.col_offset, "name": name,
721
+ "attr": name.rsplit(".", 1)[-1],
722
+ "how": "arg", "within": callee, "scope": scope,
723
+ "cls": self._classes[-1] if self._classes else "",
724
+ "glob": binder == "global",
725
+ }
726
+ )
727
+ first, _, rest = callee.partition(".")
728
+ # `patch` itself and its renamed imports count where that import binds the name, so a
729
+ # parameter, a local or another import of the name is not a patch.
730
+ known = self._patch_import(first) if first == "patch" or first in self._patchers else None
731
+ if known:
732
+ patched = _patch_target(node, f"patch.{rest}" if rest else "patch")
733
+ else:
734
+ patched = None if known is False else _patch_target(node, callee)
735
+ if patched is not None:
736
+ receiver = self._binder(patched[0]) if patched[1] not in (
737
+ "patch", "monkeypatch.setattr"
738
+ ) else ""
739
+ self._spend(1, len(scope))
740
+ self.patches.append(
741
+ {
742
+ "line": node.lineno, "target": patched[0], "how": patched[1], "scope": scope,
743
+ "local": receiver == "local", "glob": receiver == "global",
744
+ }
745
+ )
746
+ self.generic_visit(node)
747
+
748
+ def visit_Lambda(self, node: ast.Lambda) -> None:
749
+ # The defaults run where the lambda is made, outside its parameters' scope.
750
+ for default in [*node.args.defaults, *(d for d in node.args.kw_defaults if d)]:
751
+ self._value(default, "default", "<lambda>")
752
+ self.visit(default)
753
+ # The body runs in the lambda's own scope, which skips a class body's names.
754
+ self._in_function += 1
755
+ self._scope.append("<lambda>")
756
+ self._locals.append(_bound_names(node))
757
+ self._value(node.body, "return", "")
758
+ self.visit(node.body)
759
+ self._locals.pop()
760
+ self._scope.pop()
761
+ self._in_function -= 1
762
+
763
+ def _comprehension(
764
+ self, node: ast.ListComp | ast.SetComp | ast.DictComp | ast.GeneratorExp
765
+ ) -> None:
766
+ # The first iterable runs in the enclosing scope; the rest runs in the comprehension's
767
+ # own scope (named as Python names it), which skips a class body's names, and its
768
+ # targets bind there.
769
+ first, *rest = node.generators
770
+ self.visit(first.iter)
771
+ self._scope.append(_COMPREHENSION_NAMES[type(node)])
772
+ self._locals.append(
773
+ (
774
+ {
775
+ n.id
776
+ for g in node.generators
777
+ for n in ast.walk(g.target)
778
+ if isinstance(n, ast.Name)
779
+ },
780
+ set(),
781
+ set(),
782
+ )
783
+ )
784
+ for part in [first.target, *first.ifs]:
785
+ self.visit(part)
786
+ for g in rest:
787
+ self.visit(g)
788
+ for part in (
789
+ [node.key, node.value] if isinstance(node, ast.DictComp) else [node.elt]
790
+ ):
791
+ self.visit(part)
792
+ self._locals.pop()
793
+ self._scope.pop()
794
+
795
+ visit_ListComp = visit_SetComp = visit_DictComp = visit_GeneratorExp = _comprehension
796
+
797
+ def _binder(self, name: str) -> str:
798
+ """Where ``name`` is bound: "local" in an enclosing function, "global" when declared
799
+ so, "" for the file's or a function's import. ``self.method`` and ``cls.method`` are "",
800
+ a bare ``self`` is local."""
801
+ root, dot, _ = name.partition(".")
802
+ if dot and root in ("self", "cls"):
803
+ return ""
804
+ for names, imported, globals_ in reversed(self._locals):
805
+ if root in globals_:
806
+ return "global"
807
+ if root in imported:
808
+ return "" # the scope's own import: resolved through the scoped bindings
809
+ if root in names:
810
+ return "local"
811
+ return ""
812
+
813
+ def _local(self, name: str) -> bool:
814
+ return self._binder(name) == "local"
815
+
816
+
817
+ def parse_source(source: str | bytes) -> dict[str, Any]:
818
+ """Facts for one file's text, or its bytes: ``ast`` then honours a BOM and a coding line."""
819
+ empty: dict[str, Any] = {
820
+ "defs": [], "imports": [], "calls": [], "patches": [], "uses": [], "bases": [],
821
+ }
822
+ if len(source) > MAX_FILE_BYTES:
823
+ return {**empty, "error": _TOO_LARGE}
824
+ # The tree costs memory by tokens, not bytes: a dense file is refused before it is parsed.
825
+ data = source.encode("utf-8", "surrogatepass") if isinstance(source, str) else source
826
+ if sum(1 for _ in _TOKEN.finditer(data)) > MAX_FILE_TOKENS:
827
+ return {**empty, "error": f"more than {MAX_FILE_TOKENS} tokens; not indexed"}
828
+ try:
829
+ tree = ast.parse(source)
830
+ reader = _Reader()
831
+ reader.visit(tree)
832
+ except _TooDense as exc:
833
+ return {**empty, "error": str(exc)[:MAX_ERROR_TEXT]}
834
+ except (SyntaxError, ValueError, RecursionError, MemoryError) as exc:
835
+ return {**empty, "error": f"{type(exc).__name__}: {exc}"[:MAX_ERROR_TEXT]}
836
+ return {
837
+ "defs": reader.defs, "imports": reader.imports, "calls": reader.calls,
838
+ "patches": reader.patches, "uses": reader.uses, "bases": reader.bases, "error": None,
839
+ }
840
+
841
+
842
+ # --- modules and names -------------------------------------------------------------------------
843
+
844
+
845
+ @functools.lru_cache(maxsize=65_536)
846
+ def module_name(rel: str, src_package: bool = False) -> str:
847
+ """The import name of a repository file: ``src/app/x.py`` and ``app/x.py`` are ``app.x``,
848
+ unless ``src`` is itself a package (``src_package``: it has an ``__init__.py``); a package
849
+ ``__init__.py`` is the package."""
850
+ parts = list(PurePosixPath(rel).with_suffix("").parts)
851
+ if parts and parts[0] == "src" and len(parts) > 1 and not src_package:
852
+ parts = parts[1:]
853
+ if parts and parts[-1] == "__init__":
854
+ parts = parts[:-1]
855
+ return ".".join(parts)
856
+
857
+
858
+ #: The files that make a folder a project of its own, whose code imports itself from there.
859
+ _PROJECT_MARKERS = ("pyproject.toml", "setup.py", "setup.cfg")
860
+
861
+
862
+ def project_roots(files: Iterable[str]) -> frozenset[str]:
863
+ """The nested project folders among ``files``: each holds a ``pyproject.toml``,
864
+ ``setup.py`` or ``setup.cfg``, as an app in a monorepo does, and its code imports its
865
+ modules from there (``apps/api`` on the path, ``from src.repositories import factory``)."""
866
+ return frozenset(
867
+ rel.rpartition("/")[0] for rel in files
868
+ if "/" in rel and rel.rpartition("/")[2] in _PROJECT_MARKERS
869
+ )
870
+
871
+
872
+ def root_of(rel: str, roots: frozenset[str]) -> str:
873
+ """The innermost project root holding ``rel``, or "": one lookup per enclosing folder, so
874
+ the cost follows the path's depth, not the number of roots."""
875
+ parts = rel.split("/")[:-1]
876
+ for depth in range(len(parts), 0, -1):
877
+ folder = "/".join(parts[:depth])
878
+ if folder in roots:
879
+ return folder
880
+ return ""
881
+
882
+
883
+ def _absolute(module: str, is_package: bool, level: int, target: str) -> str:
884
+ """The absolute module of ``from <level dots><target> import ...`` written in ``module``."""
885
+ if level == 0:
886
+ return target
887
+ base = module.split(".") if module else []
888
+ if not is_package:
889
+ base = base[:-1]
890
+ if level > 1:
891
+ base = base[: len(base) - (level - 1)] if level - 1 <= len(base) else []
892
+ return ".".join([*base, target] if target else base)
893
+
894
+
895
+ def bindings(
896
+ rel: str, facts: dict[str, Any], src_package: bool = False
897
+ ) -> dict[tuple[str, bool], list[tuple[str, str, int]]]:
898
+ """(scope, deep) -> every (local name, the full dotted name it stands for, the line from
899
+ which it holds), in source order. Scope "" holds the module-level imports and definitions;
900
+ an import inside a function or class body binds only there. Every binding is kept, not only
901
+ the last: an expression at module or class level means the binding of its own moment
902
+ (``create = create(1)`` calls the import). A definition holds from the line after it ends."""
903
+ module = module_name(rel, src_package)
904
+ is_package = PurePosixPath(rel).name == "__init__.py"
905
+ scopes: dict[tuple[str, bool], list[tuple[str, str, int]]] = {("", True): []}
906
+ bound: list[tuple[int, tuple[str, bool], str, str]] = []
907
+ for imp in facts["imports"]:
908
+ source = _absolute(module, is_package, imp["level"], imp["module"])
909
+ key = (imp["scope"], imp["deep"]) if imp["scope"] else ("", True)
910
+ target = (f"{source}.{imp['name']}" if source else imp["name"]) if imp["name"] else source
911
+ bound.append((imp["line"], key, imp["alias"], target))
912
+ for d in facts["defs"]:
913
+ if "." not in d["name"]:
914
+ target = f"{module}.{d['name']}" if module else d["name"]
915
+ bound.append((d["end"], ("", True), d["name"], target))
916
+ for line, key, alias, target in sorted(bound, key=lambda b: b[0]):
917
+ scopes.setdefault(key, []).append((alias, target, line))
918
+ return scopes
919
+
920
+
921
+ # --- the store ---------------------------------------------------------------------------------
922
+ #
923
+ # The index is a SQLite database in the cache directory, not a JSON view: a large backend has
924
+ # hundreds of thousands of call sites, and a query reads only the rows for the name it asks about.
925
+ # A changed file replaces its own rows, and a removed file's rows go with it. Every row is
926
+ # checked when it is read: a database that does not hold what this core writes is not trusted.
927
+
928
+
929
+ def _running_scope(scope: str) -> str:
930
+ """The scope whose execution point code in ``scope`` runs at: a list, set or dict
931
+ comprehension runs where it is written, so at module level it means the bindings of that
932
+ moment, though its names live in a scope of their own."""
933
+ while scope.rpartition(".")[2] in _EAGER:
934
+ scope = scope.rpartition(".")[0]
935
+ return scope
936
+
937
+
938
+ def _batches(values: set[str]) -> Iterator[list[str]]:
939
+ ordered = sorted(values)
940
+ for start in range(0, len(ordered), _BATCH):
941
+ yield ordered[start : start + _BATCH]
942
+
943
+
944
+ class CorruptIndex(RuntimeError):
945
+ """The code index holds rows this core did not write; ``terrain refresh code`` rebuilds it."""
946
+
947
+
948
+ class UnsafeIndex(CorruptIndex):
949
+ """The code index's path is a link, or another user can change it or a folder above it."""
950
+
951
+
952
+ _TABLES = ("meta", "facts", "files", "defs", "binds", "calls", "patches", "uses")
953
+ _SCHEMA = """
954
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
955
+ CREATE TABLE IF NOT EXISTS files (
956
+ id INTEGER PRIMARY KEY, rel TEXT NOT NULL UNIQUE, sha TEXT NOT NULL, test INTEGER NOT NULL,
957
+ error TEXT);
958
+ CREATE TABLE IF NOT EXISTS defs (
959
+ full TEXT NOT NULL, tail TEXT NOT NULL, file INTEGER NOT NULL, json TEXT NOT NULL);
960
+ CREATE INDEX IF NOT EXISTS defs_full ON defs (full);
961
+ CREATE INDEX IF NOT EXISTS defs_tail ON defs (tail);
962
+ CREATE INDEX IF NOT EXISTS defs_file ON defs (file);
963
+ CREATE TABLE IF NOT EXISTS binds (
964
+ file INTEGER NOT NULL, alias TEXT NOT NULL, name TEXT NOT NULL, target TEXT NOT NULL,
965
+ tail TEXT NOT NULL, scope TEXT NOT NULL, deep INTEGER NOT NULL, line INTEGER NOT NULL);
966
+ CREATE INDEX IF NOT EXISTS binds_file ON binds (file);
967
+ CREATE INDEX IF NOT EXISTS binds_name ON binds (name);
968
+ CREATE INDEX IF NOT EXISTS binds_tail ON binds (tail);
969
+ CREATE INDEX IF NOT EXISTS binds_alias ON binds (file, alias, scope, line);
970
+ CREATE TABLE IF NOT EXISTS calls (file INTEGER NOT NULL, attr TEXT NOT NULL, json TEXT NOT NULL);
971
+ CREATE INDEX IF NOT EXISTS calls_attr ON calls (attr);
972
+ CREATE INDEX IF NOT EXISTS calls_file ON calls (file);
973
+ CREATE TABLE IF NOT EXISTS patches (
974
+ file INTEGER NOT NULL, tail TEXT NOT NULL, line INTEGER NOT NULL, json TEXT NOT NULL);
975
+ CREATE INDEX IF NOT EXISTS patches_tail ON patches (tail);
976
+ CREATE INDEX IF NOT EXISTS patches_file ON patches (file, line);
977
+ CREATE TABLE IF NOT EXISTS uses (file INTEGER NOT NULL, attr TEXT NOT NULL, json TEXT NOT NULL);
978
+ CREATE INDEX IF NOT EXISTS uses_attr ON uses (attr);
979
+ CREATE INDEX IF NOT EXISTS uses_file ON uses (file);
980
+ CREATE TABLE IF NOT EXISTS bases (file INTEGER NOT NULL, cls TEXT NOT NULL, tail TEXT NOT NULL,
981
+ json TEXT NOT NULL);
982
+ CREATE INDEX IF NOT EXISTS bases_class ON bases (file, cls);
983
+ CREATE INDEX IF NOT EXISTS bases_tail ON bases (tail);
984
+ """
985
+
986
+
987
+ def _span(row: dict[str, Any]) -> tuple[tuple[int, int], tuple[int, int]]:
988
+ """A definition's (line, column) start and the (line, column) at which it binds."""
989
+ return (row["line"], row["col"]), (row["end"], row["endcol"])
990
+
991
+
992
+ def _typed(row: Any, types: dict[str, Any]) -> bool:
993
+ if not isinstance(row, dict) or set(row) != set(types):
994
+ return False
995
+ for key, kind in types.items():
996
+ value = row[key]
997
+ if kind == "int":
998
+ if not isinstance(value, int) or isinstance(value, bool):
999
+ return False
1000
+ elif kind == "strs":
1001
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
1002
+ return False
1003
+ elif not isinstance(value, kind):
1004
+ return False
1005
+ return True
1006
+
1007
+
1008
+ def _valid_facts(facts: Any) -> bool:
1009
+ return (
1010
+ isinstance(facts, dict)
1011
+ and set(facts) == {"defs", "imports", "calls", "patches", "uses", "bases", "error"}
1012
+ and (facts["error"] is None or isinstance(facts["error"], str))
1013
+ and all(_typed(d, DEF_TYPES) for d in facts["defs"])
1014
+ and all(_typed(i, IMPORT_TYPES) for i in facts["imports"])
1015
+ and all(_typed(c, CALL_TYPES) for c in facts["calls"])
1016
+ and all(_typed(p, PATCH_TYPES) for p in facts["patches"])
1017
+ and all(_typed(u, USE_TYPES) for u in facts["uses"])
1018
+ and all(_typed(b, BASE_TYPES) for b in facts["bases"])
1019
+ )
1020
+
1021
+
1022
+ def _row(text: Any, types: dict[str, Any]) -> dict[str, Any]:
1023
+ """A stored row back as a dict, checked against ``types``."""
1024
+ try:
1025
+ values = json.loads(text)
1026
+ except (TypeError, ValueError, RecursionError):
1027
+ values = None
1028
+ value = (
1029
+ dict(zip(types, values, strict=True))
1030
+ if isinstance(values, list) and len(values) == len(types)
1031
+ else None
1032
+ )
1033
+ if not _typed(value, types):
1034
+ raise CorruptIndex("the code index holds a row this version did not write")
1035
+ return value # type: ignore[return-value]
1036
+
1037
+
1038
+ def _pack(row: dict[str, Any], types: dict[str, Any]) -> str:
1039
+ """A row as a JSON array in the order of ``types``: the keys are not stored."""
1040
+ return json.dumps([row[key] for key in types], separators=(",", ":"))
1041
+
1042
+
1043
+ def db_path(cache: Cache) -> Path:
1044
+ # A folder of its own: SQLite creates its journal beside the database, and no one else may.
1045
+ return _index_home(cache) / f"code-{cache.root_id}" / "index.sqlite3"
1046
+
1047
+
1048
+ def _index_home(cache: Cache) -> Path:
1049
+ """Where the index folder goes. On Windows a cache outside the user's profile (in the
1050
+ checkout, on a drive whose ACL lets every user change it) cannot be made private without
1051
+ ACL calls, so the index goes in the per-user cache under the profile instead."""
1052
+ if ON_WINDOWS and not _contains(
1053
+ Path(os.path.realpath(Path.home())), Path(os.path.realpath(cache.dir))
1054
+ ):
1055
+ return user_cache_dir(cache.root)
1056
+ return cache.dir
1057
+
1058
+
1059
+ def _is_link(info: os.stat_result) -> bool:
1060
+ reparse = getattr(info, "st_file_attributes", 0) & getattr(
1061
+ stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0
1062
+ )
1063
+ return stat.S_ISLNK(info.st_mode) or bool(reparse)
1064
+
1065
+
1066
+ def _refuse_unsafe(path: Path) -> None:
1067
+ """Refuse a database SQLite would open through a link, or one another user could swap or
1068
+ plant files beside: SQLite follows a link, creates its journal next to the database, and a
1069
+ rebuild drops the tables of whatever database it opened. Everywhere, the database's own
1070
+ folder and the database are no links and the database has no other name. On POSIX that
1071
+ folder belongs to the user and only the user can write it, and every folder above belongs to
1072
+ the user (or root) and is writable by no one else unless sticky (``/tmp``), as ssh requires."""
1073
+ hint = "; use --cache-dir or CODETERRAIN_CACHE_DIR under your home directory"
1074
+ own = os.lstat(path.parent)
1075
+ if _is_link(own) or not stat.S_ISDIR(own.st_mode):
1076
+ raise UnsafeIndex(f"the code index folder is a link: {path.parent}{hint}")
1077
+ try:
1078
+ info = os.lstat(path)
1079
+ except FileNotFoundError:
1080
+ info = None
1081
+ if info is not None and (_is_link(info) or not stat.S_ISREG(info.st_mode)):
1082
+ raise UnsafeIndex(f"the code index is not a regular file: {path}{hint}")
1083
+ if info is not None and info.st_nlink != 1:
1084
+ raise UnsafeIndex(f"the code index has another name (a hard link): {path}{hint}")
1085
+ if ON_WINDOWS:
1086
+ return
1087
+ uid = os.getuid()
1088
+ if info is not None and info.st_uid != uid:
1089
+ raise UnsafeIndex(f"the code index belongs to another user: {path}{hint}")
1090
+ if own.st_uid != uid or own.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
1091
+ raise UnsafeIndex(f"another user can change the code index folder {path.parent}{hint}")
1092
+ folder = Path(os.path.abspath(path.parent.parent))
1093
+ for d in (folder, *folder.parents):
1094
+ st = os.stat(d)
1095
+ others_write = st.st_mode & (stat.S_IWGRP | stat.S_IWOTH)
1096
+ if st.st_uid not in (uid, 0) or (others_write and not st.st_mode & stat.S_ISVTX):
1097
+ raise UnsafeIndex(f"another user can change the code index folder {d}{hint}")
1098
+
1099
+
1100
+ def _connect(cache: Cache) -> sqlite3.Connection:
1101
+ path = db_path(cache)
1102
+ _private_dir(path.parent)
1103
+ _refuse_unsafe(path)
1104
+ if not path.exists():
1105
+ try:
1106
+ _create_private(path, b"") # 0600 on POSIX before SQLite writes anything
1107
+ except FileExistsError:
1108
+ pass
1109
+ # Checked again after the create: a link that won the race to the name is refused. Past this
1110
+ # point the file is the user's own, and the folders above it let no one else replace it.
1111
+ _refuse_unsafe(path)
1112
+ before = os.lstat(path)
1113
+ db = sqlite3.connect(path, timeout=30, isolation_level=None)
1114
+ after = os.lstat(path)
1115
+ if (after.st_ino, after.st_dev, after.st_nlink) != (before.st_ino, before.st_dev, 1):
1116
+ db.close()
1117
+ raise UnsafeIndex(f"the code index changed while it was opened: {path}")
1118
+ db.execute("PRAGMA journal_mode=DELETE")
1119
+ return db
1120
+
1121
+
1122
+ def _reset(db: sqlite3.Connection) -> None:
1123
+ for table in _TABLES:
1124
+ db.execute(f"DROP TABLE IF EXISTS {table}")
1125
+ for statement in _SCHEMA.split(";"):
1126
+ if statement.strip():
1127
+ db.execute(statement)
1128
+ db.execute("INSERT INTO meta VALUES ('format', ?)", (OBJECT_KIND,))
1129
+
1130
+
1131
+ def _current(db: sqlite3.Connection) -> bool:
1132
+ try:
1133
+ row = db.execute("SELECT value FROM meta WHERE key = 'format'").fetchone()
1134
+ except sqlite3.DatabaseError:
1135
+ return False
1136
+ return row is not None and row[0] == OBJECT_KIND
1137
+
1138
+
1139
+ def _meta(db: sqlite3.Connection, key: str) -> str | None:
1140
+ try:
1141
+ row = db.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone()
1142
+ except sqlite3.DatabaseError:
1143
+ return None
1144
+ return row[0] if row is not None and isinstance(row[0], str) else None
1145
+
1146
+
1147
+ def _roots(value: str | None) -> frozenset[str]:
1148
+ try:
1149
+ roots = json.loads(value or "[]")
1150
+ except ValueError:
1151
+ return frozenset()
1152
+ if not isinstance(roots, list):
1153
+ return frozenset()
1154
+ return frozenset(r for r in roots if isinstance(r, str))
1155
+
1156
+
1157
+ def _src_package(db: sqlite3.Connection) -> bool | None:
1158
+ value = _meta(db, "src_package")
1159
+ return None if value is None else value == "1"
1160
+
1161
+
1162
+ def _escaped(rel: str) -> str:
1163
+ """``rel`` itself, or for a path holding bytes that are not UTF-8 (surrogate escapes) the
1164
+ same path with those characters written as ``\\udcNN``, which SQLite can store."""
1165
+ try:
1166
+ rel.encode("utf-8")
1167
+ except UnicodeEncodeError:
1168
+ return rel.encode("utf-8", "backslashreplace").decode("utf-8")
1169
+ return rel
1170
+
1171
+
1172
+ def _facts(cache: Cache, rel: str) -> dict[str, Any]:
1173
+ with open(cache.root / rel, "rb") as f:
1174
+ data = f.read(MAX_FILE_BYTES + 1) # never more than the cap, whatever the file's size
1175
+ if len(data) > MAX_FILE_BYTES:
1176
+ return {**parse_source(""), "error": _TOO_LARGE}
1177
+ return parse_source(data)
1178
+
1179
+
1180
+ def _forget(db: sqlite3.Connection, rel: str) -> None:
1181
+ row = db.execute("SELECT id FROM files WHERE rel = ?", (rel,)).fetchone()
1182
+ if row is None:
1183
+ return
1184
+ for table in ("defs", "binds", "calls", "patches", "uses", "bases"):
1185
+ db.execute(f"DELETE FROM {table} WHERE file = ?", (row[0],))
1186
+ db.execute("DELETE FROM files WHERE id = ?", (row[0],))
1187
+
1188
+
1189
+ def _record(
1190
+ db: sqlite3.Connection, rel: str, sha: str, test: bool, facts: dict[str, Any],
1191
+ src_package: bool,
1192
+ ) -> None:
1193
+ module = module_name(rel, src_package)
1194
+ bound = bindings(rel, facts, src_package)
1195
+ # The facts are kept per content, so the parse could not charge the module name each
1196
+ # definition and binding is stored under: a long path multiplies the stored size.
1197
+ prefixed = len(facts["defs"]) + sum(len(b) for b in bound.values())
1198
+ if module and (len(module) + 1) * prefixed > MAX_FILE_FACT_BYTES:
1199
+ db.execute(
1200
+ "INSERT INTO files (rel, sha, test, error) VALUES (?, ?, ?, ?)",
1201
+ (rel, sha, int(test),
1202
+ f"more than {MAX_FILE_FACT_BYTES} bytes of names under its module path; not indexed"),
1203
+ )
1204
+ return
1205
+ fid = db.execute(
1206
+ "INSERT INTO files (rel, sha, test, error) VALUES (?, ?, ?, ?)",
1207
+ (rel, sha, int(test), facts["error"]),
1208
+ ).lastrowid
1209
+ db.executemany(
1210
+ "INSERT INTO defs VALUES (?, ?, ?, ?)",
1211
+ [
1212
+ (f"{module}.{d['name']}" if module else d["name"], d["name"].rsplit(".", 1)[-1], fid,
1213
+ _pack(d, DEF_TYPES))
1214
+ for d in facts["defs"]
1215
+ ],
1216
+ )
1217
+ db.executemany(
1218
+ "INSERT INTO binds VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
1219
+ [
1220
+ (fid, alias, f"{module}.{alias}" if module else alias, target,
1221
+ target.rsplit(".", 1)[-1], scope, int(deep), line)
1222
+ for (scope, deep), rows in bound.items()
1223
+ for alias, target, line in rows
1224
+ ],
1225
+ )
1226
+ db.executemany(
1227
+ "INSERT INTO calls VALUES (?, ?, ?)",
1228
+ [(fid, c["attr"], _pack(c, CALL_TYPES)) for c in facts["calls"]],
1229
+ )
1230
+ db.executemany(
1231
+ "INSERT INTO patches VALUES (?, ?, ?, ?)",
1232
+ [
1233
+ (fid, p["target"].rsplit(".", 1)[-1], p["line"], _pack(p, PATCH_TYPES))
1234
+ for p in facts["patches"]
1235
+ ],
1236
+ )
1237
+ db.executemany(
1238
+ "INSERT INTO uses VALUES (?, ?, ?)",
1239
+ [(fid, u["attr"], _pack(u, USE_TYPES)) for u in facts["uses"]],
1240
+ )
1241
+ db.executemany(
1242
+ "INSERT INTO bases VALUES (?, ?, ?, ?)",
1243
+ [
1244
+ (fid, b["cls"], b["base"].rsplit(".", 1)[-1], _pack(b, BASE_TYPES))
1245
+ for b in facts["bases"]
1246
+ ],
1247
+ )
1248
+
1249
+
1250
+ def _installed(directory: Path) -> bool:
1251
+ """A directory packages were installed into (it holds ``*.dist-info``, or an ``*.egg-info``
1252
+ that records installed files or has no source package beside it), such as a vendored
1253
+ dependency folder for a deployment bundle: not the repository's own code. An egg-info next
1254
+ to its package is an editable install of the repository itself, found by the distribution's
1255
+ name (``src/app.egg-info`` beside ``src/app``) or by the import packages its
1256
+ ``top_level.txt`` lists, since the two names need not match."""
1257
+ try:
1258
+ with os.scandir(directory) as entries:
1259
+ names = {e.name for e in entries}
1260
+ except OSError:
1261
+ return False
1262
+ if any(n.endswith(".dist-info") for n in names):
1263
+ return True
1264
+ for n in names:
1265
+ if not n.endswith(".egg-info"):
1266
+ continue
1267
+ if _regular(directory / n, "installed-files.txt"):
1268
+ return True
1269
+ name = n.removesuffix(".egg-info")
1270
+ packages = {name, name.replace("-", "_"), *_top_level(directory / n)}
1271
+ if not names & (packages | {f"{p}.py" for p in packages}):
1272
+ return True
1273
+ return False
1274
+
1275
+
1276
+ def _regular(folder: Path, name: str) -> bool:
1277
+ """Whether ``folder/name`` is a regular file reached through no link: package metadata
1278
+ in the checkout is read only there, never through a link out of it or from a device."""
1279
+ try:
1280
+ for path in (folder, folder / name):
1281
+ info = os.lstat(path)
1282
+ if _is_link(info):
1283
+ return False
1284
+ except OSError:
1285
+ return False
1286
+ return stat.S_ISREG(info.st_mode)
1287
+
1288
+
1289
+ def _top_level(egg_info: Path) -> set[str]:
1290
+ """The import packages an egg-info's ``top_level.txt`` lists (read up to 64 KB), from a
1291
+ regular file reached through no link."""
1292
+ if not _regular(egg_info, "top_level.txt"):
1293
+ return set()
1294
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
1295
+ flags |= getattr(os, "O_BINARY", 0)
1296
+ try:
1297
+ fd = os.open(egg_info / "top_level.txt", flags)
1298
+ except OSError:
1299
+ return set()
1300
+ with os.fdopen(fd, "rb") as f:
1301
+ # Swapped for something else since the check: refused, not read.
1302
+ if not stat.S_ISREG(os.fstat(f.fileno()).st_mode):
1303
+ return set()
1304
+ try:
1305
+ text = f.read(65_536).decode("utf-8", "replace")
1306
+ except OSError:
1307
+ return set()
1308
+ return {line.strip() for line in text.splitlines() if line.strip()}
1309
+
1310
+
1311
+ def _own_code(cache: Cache, fingerprint: dict[str, str]) -> dict[str, str]:
1312
+ """``fingerprint`` without the files under an installed-packages directory."""
1313
+ verdict: dict[str, bool] = {}
1314
+
1315
+ def vendored(parts: tuple[str, ...]) -> bool:
1316
+ for depth in range(1, len(parts)):
1317
+ key = "/".join(parts[:depth])
1318
+ if key not in verdict:
1319
+ verdict[key] = _installed(cache.root / key)
1320
+ if verdict[key]:
1321
+ return True
1322
+ return False
1323
+
1324
+ return {rel: sha for rel, sha in fingerprint.items() if not vendored(tuple(rel.split("/")))}
1325
+
1326
+
1327
+ def update(cache: Cache, config: Config, force: bool = False) -> sqlite3.Connection:
1328
+ """The code index, brought up to date with the files on disk: only files whose content
1329
+ changed are read again. Returns an open connection for queries."""
1330
+ found = cache.scan(None, (".py", *(m for m in _PROJECT_MARKERS if not m.endswith(".py"))))
1331
+ cache.flush()
1332
+ fingerprint = _own_code(cache, {rel: sha for rel, sha in found.items() if rel.endswith(".py")})
1333
+ # A path that is not valid UTF-8 (a POSIX file name byte) cannot be stored: it is kept under
1334
+ # an escaped name, reported and not read.
1335
+ undecodable = {
1336
+ _escaped(rel): sha for rel, sha in fingerprint.items() if _escaped(rel) != rel
1337
+ }
1338
+ fingerprint = {
1339
+ rel: sha for rel, sha in fingerprint.items() if _escaped(rel) == rel
1340
+ } | undecodable
1341
+ roots = json.dumps(sorted(project_roots(found)))
1342
+ tests = tuple(p.rstrip("/") + "/" for p in config.test_paths)
1343
+ db = _connect(cache)
1344
+ try:
1345
+ db.execute("BEGIN IMMEDIATE")
1346
+ # Whether `src/` is a package decides every module name under it: a change rebuilds.
1347
+ src_package = "src/__init__.py" in fingerprint
1348
+ rebuilt = force or not _current(db) or _src_package(db) != src_package
1349
+ if rebuilt:
1350
+ _reset(db)
1351
+ db.execute("INSERT INTO meta VALUES ('src_package', ?)", (str(int(src_package)),))
1352
+ try:
1353
+ known = {
1354
+ rel: sha for rel, sha in db.execute("SELECT rel, sha FROM files")
1355
+ if isinstance(rel, str) and isinstance(sha, str)
1356
+ }
1357
+ except sqlite3.DatabaseError:
1358
+ _reset(db)
1359
+ db.execute("INSERT INTO meta VALUES ('src_package', ?)", (str(int(src_package)),))
1360
+ rebuilt, known = True, {}
1361
+ changed = [rel for rel, sha in fingerprint.items() if known.get(rel) != sha]
1362
+ for rel in [*changed, *(rel for rel in known if rel not in fingerprint)]:
1363
+ _forget(db, rel)
1364
+ for rel in changed:
1365
+ if rel in undecodable:
1366
+ facts = {**parse_source(""), "error": _UNDECODABLE}
1367
+ else:
1368
+ facts = _facts(cache, rel)
1369
+ test = is_test_module(rel) or rel.startswith(tests)
1370
+ _record(db, rel, fingerprint[rel], test, facts, src_package)
1371
+ # A change of `test_paths` reclassifies the files already read; their facts still hold.
1372
+ test_paths = json.dumps(sorted(tests))
1373
+ if _meta(db, "test_paths") != test_paths:
1374
+ db.executemany(
1375
+ "UPDATE files SET test = ? WHERE id = ?",
1376
+ [
1377
+ (int(is_test_module(rel) or rel.startswith(tests)), fid)
1378
+ for fid, rel in db.execute("SELECT id, rel FROM files").fetchall()
1379
+ if isinstance(rel, str)
1380
+ ],
1381
+ )
1382
+ db.execute("INSERT OR REPLACE INTO meta VALUES ('test_paths', ?)", (test_paths,))
1383
+ # The project roots only steer how imports are looked up: nothing is read again.
1384
+ if _meta(db, "roots") != roots:
1385
+ db.execute("INSERT OR REPLACE INTO meta VALUES ('roots', ?)", (roots,))
1386
+ if changed or len(known) != len(fingerprint):
1387
+ db.execute("INSERT OR REPLACE INTO meta VALUES ('built_at', ?)", (utc_now(),))
1388
+ db.execute("COMMIT")
1389
+ if rebuilt:
1390
+ db.execute("VACUUM") # the dropped tables' pages, returned to the disk
1391
+ except BaseException:
1392
+ if db.in_transaction:
1393
+ db.execute("ROLLBACK")
1394
+ db.close()
1395
+ raise
1396
+ return db
1397
+
1398
+
1399
+ def summary(db: sqlite3.Connection) -> dict[str, Any]:
1400
+ """Counts, when the index last changed, and the files that could not be read."""
1401
+ files = db.execute("SELECT COUNT(*) FROM files").fetchone()[0]
1402
+ defs = db.execute("SELECT COUNT(*) FROM defs").fetchone()[0]
1403
+ built = db.execute("SELECT value FROM meta WHERE key = 'built_at'").fetchone()
1404
+ errors = [
1405
+ {"file": rel, "error": error}
1406
+ for rel, error in db.execute(
1407
+ "SELECT rel, error FROM files WHERE error IS NOT NULL ORDER BY rel"
1408
+ )
1409
+ if isinstance(rel, str) and isinstance(error, str)
1410
+ ]
1411
+ return {
1412
+ "files": files, "defs": defs, "built_at": built[0] if built else "", "errors": errors,
1413
+ }
1414
+
1415
+
1416
+ # --- questions ---------------------------------------------------------------------------------
1417
+
1418
+
1419
+ class Index:
1420
+ """Queries over the code index; rows are read on demand and checked as they are read."""
1421
+
1422
+ def __init__(self, db: sqlite3.Connection) -> None:
1423
+ self.db = db
1424
+ # How many ``instance_of`` lookups are open: a variable made from another variable's
1425
+ # member (`a = b.Make()`, `b = a.Make()`) recurses, so the chain is bounded.
1426
+ self._making = 0
1427
+ self.src_package = bool(_src_package(db))
1428
+ self.roots = _roots(_meta(db, "roots"))
1429
+ self._known: dict[str, bool] = {}
1430
+ self._defs: dict[str, tuple[str, dict[str, Any]] | None] = {}
1431
+ self._defs_bytes = 0
1432
+ self._binds: dict[tuple[str, str, str, int], dict[str, tuple[bool, str, int]]] = {}
1433
+ self._tests: dict[str, bool] = {}
1434
+ # File -> its innermost project root: a deep file pays for its depth once, not per row.
1435
+ self._root: dict[str, str] = {}
1436
+ self.find_cut = False
1437
+
1438
+ def definition(self, full: str) -> tuple[str, dict[str, Any]] | None:
1439
+ """The definition of ``full``: within a file the last one, as Python binds it, so the
1440
+ implementation after its ``@overload`` stubs (rows are stored in source order)."""
1441
+ if full not in self._defs:
1442
+ row = self.db.execute(
1443
+ "SELECT f.rel, d.json FROM defs d JOIN files f ON f.id = d.file"
1444
+ " WHERE d.full = ? ORDER BY f.rel, d.rowid DESC LIMIT 1",
1445
+ (full,),
1446
+ ).fetchone()
1447
+ # Bounded by count and by the stored size of what it holds: a few receivers with
1448
+ # huge signatures would otherwise keep every decoded one for the whole query.
1449
+ size = len(row[1]) if row is not None and isinstance(row[1], str) else 0
1450
+ if (
1451
+ len(self._defs) >= MAX_CACHED
1452
+ or self._defs_bytes + size > MAX_CACHED_DEF_BYTES
1453
+ ):
1454
+ self._defs.clear()
1455
+ self._defs_bytes = 0
1456
+ self._defs[full] = None if row is None else (row[0], _row(row[1], DEF_TYPES))
1457
+ self._defs_bytes += size
1458
+ return self._defs[full]
1459
+
1460
+ def spans(self, rel: str, full: str) -> list[tuple[tuple[int, int], tuple[int, int]]]:
1461
+ """Where each definition of ``full`` in ``rel`` (every assignment of a variable) starts
1462
+ and where it binds, as (line, column) pairs, in source order."""
1463
+ out = []
1464
+ for (raw,) in self.db.execute(
1465
+ "SELECT d.json FROM defs d JOIN files f ON f.id = d.file"
1466
+ " WHERE f.rel = ? AND d.full = ? ORDER BY d.rowid", (rel, full),
1467
+ ):
1468
+ row = _row(raw, DEF_TYPES)
1469
+ out.append(_span(row))
1470
+ return out
1471
+
1472
+ def bases(self, rel: str, cls: str) -> list[str]:
1473
+ """The bases of class ``cls`` (its qualified name) in ``rel``, as the index names them;
1474
+ a base it cannot place is left out."""
1475
+ module = module_name(rel, self.src_package)
1476
+ scope = cls.rpartition(".")[0]
1477
+ out = []
1478
+ for (raw,) in self.db.execute(
1479
+ "SELECT b.json FROM bases b JOIN files f ON f.id = b.file"
1480
+ " WHERE f.rel = ? AND b.cls = ? ORDER BY b.rowid", (rel, cls),
1481
+ ):
1482
+ row = _row(raw, BASE_TYPES)
1483
+ head, _, rest = row["base"].partition(".")
1484
+ bound = self.binding(rel, scope, head, row["line"])
1485
+ if bound is not None:
1486
+ full = f"{bound}.{rest}" if rest else bound
1487
+ else:
1488
+ local = f"{scope}.{head}" if scope else head
1489
+ full = f"{module}.{local}" if module else local
1490
+ full = f"{full}.{rest}" if rest else full
1491
+ out.append(self.resolve(full))
1492
+ return out
1493
+
1494
+ def members(self, rel: str, cls: str) -> dict[str, dict[str, Any]]:
1495
+ """The names defined directly in class ``cls`` of ``rel`` (methods, nested classes and
1496
+ class attributes), each with its definition row."""
1497
+ module = module_name(rel, self.src_package)
1498
+ prefix = f"{module}.{cls}" if module else cls
1499
+ out: dict[str, dict[str, Any]] = {}
1500
+ for (raw,) in self.db.execute(
1501
+ "SELECT d.json FROM defs d JOIN files f ON f.id = d.file"
1502
+ " WHERE f.rel = ? AND d.full > ? AND d.full < ? ORDER BY d.rowid",
1503
+ (rel, f"{prefix}.", f"{prefix}/"),
1504
+ ):
1505
+ row = _row(raw, DEF_TYPES)
1506
+ owner, _, name = row["name"].rpartition(".")
1507
+ if owner == cls:
1508
+ out[name] = row
1509
+ return out
1510
+
1511
+ def modules_named(self, name: str, limit: int = MAX_MATCHES) -> list[tuple[str, str]]:
1512
+ """(module, file) of each indexed module called ``name``: its full dotted name, or one
1513
+ ending in ``.name``; at most ``limit``, exact matches first. A namespace package (no
1514
+ ``__init__.py``) is named by the modules below it, with its folder as the file."""
1515
+ exact, tail = [], []
1516
+ known: dict[str, str] = {}
1517
+ packages: dict[str, str] = {}
1518
+ for (rel,) in self.db.execute("SELECT rel FROM files WHERE error IS NULL ORDER BY rel"):
1519
+ if not isinstance(rel, str):
1520
+ continue
1521
+ module = module_name(rel, self.src_package)
1522
+ known.setdefault(module, rel)
1523
+ parts, folder = module.split("."), PurePosixPath(rel).parent
1524
+ if PurePosixPath(rel).name == "__init__.py":
1525
+ folder = folder.parent
1526
+ for depth in range(len(parts) - 1, 0, -1):
1527
+ packages.setdefault(".".join(parts[:depth]), f"{folder.as_posix()}/")
1528
+ folder = folder.parent
1529
+ for module, rel in [*known.items(), *(
1530
+ (m, f) for m, f in packages.items() if m not in known
1531
+ )]:
1532
+ if module == name:
1533
+ exact.append((module, rel))
1534
+ elif module.endswith(f".{name}"):
1535
+ tail.append((module, rel))
1536
+ return (sorted(exact) + sorted(tail))[:limit]
1537
+
1538
+ def _imported(
1539
+ self, where: str, args: tuple[Any, ...], limit: int
1540
+ ) -> Iterator[dict[str, Any]]:
1541
+ """Import bindings matching ``where``, each target as the index names it (a nested
1542
+ project's own imports resolved from its folder). A definition is kept as a binding too:
1543
+ it is the one whose full name the same file defines."""
1544
+ for rel, test, alias, target, scope, line in self.db.execute(
1545
+ "SELECT f.rel, f.test, b.alias, b.target, b.scope, b.line FROM binds b"
1546
+ f" JOIN files f ON f.id = b.file WHERE ({where}) AND NOT EXISTS ("
1547
+ "SELECT 1 FROM defs d WHERE d.full = b.target AND d.file = b.file)"
1548
+ " ORDER BY f.rel, b.line LIMIT ?",
1549
+ (*args, limit),
1550
+ ):
1551
+ if not all(isinstance(v, str) for v in (rel, alias, target, scope)):
1552
+ continue
1553
+ yield {
1554
+ "file": rel, "line": line, "scope": scope, "imports": self.located(rel, target),
1555
+ "as": alias, "test": bool(test),
1556
+ }
1557
+
1558
+ def importers(self, module: str, limit: int = MAX_IMPORTS) -> list[dict[str, Any]]:
1559
+ """Each import, in another file, of ``module`` or of a name from it (``import m``,
1560
+ ``from m import f``, ``from m.sub import g``), with the file, line and local name. In a
1561
+ nested project the module is also imported by its name from the project's folder
1562
+ (``from lib import f`` for ``apps.web.lib``), so that spelling is looked for too."""
1563
+ spellings = [module]
1564
+ for root in sorted(self.roots, key=len, reverse=True):
1565
+ base = root.replace("/", ".")
1566
+ if module.startswith(f"{base}."):
1567
+ local = module[len(base) + 1:]
1568
+ spellings += [local, local[4:]] if local.startswith("src.") else [local]
1569
+ break
1570
+ where = " OR ".join(["b.target = ? OR substr(b.target, 1, ?) = ?"] * len(spellings))
1571
+ args = tuple(v for m in spellings for v in (m, len(m) + 1, f"{m}."))
1572
+ return [
1573
+ row for row in self._imported(where, args, limit)
1574
+ if (row["imports"] == module or row["imports"].startswith(f"{module}."))
1575
+ and module_name(row["file"], self.src_package) != module
1576
+ ]
1577
+
1578
+ def imports_of(self, rel: str, limit: int = MAX_IMPORTS) -> list[dict[str, Any]]:
1579
+ """What the file ``rel`` imports, each resolved to the full name it binds."""
1580
+ return list(self._imported("f.rel = ?", (rel,), limit))
1581
+
1582
+ def classes_with_bases(self, limit: int) -> list[tuple[str, str]]:
1583
+ """(file, class) of up to ``limit`` classes in test files that have a base."""
1584
+ return [
1585
+ (rel, cls) for rel, cls in self.db.execute(
1586
+ "SELECT DISTINCT f.rel, b.cls FROM bases b JOIN files f ON f.id = b.file"
1587
+ " WHERE f.test = 1 ORDER BY f.rel, b.cls LIMIT ?", (limit,),
1588
+ )
1589
+ ]
1590
+
1591
+ def subclasses_named(self, tail: str) -> list[tuple[str, str]]:
1592
+ """(file, class) of every class with a base whose last part is ``tail``: candidates,
1593
+ to be checked with ``bases``."""
1594
+ return [
1595
+ (rel, cls) for rel, cls in self.db.execute(
1596
+ "SELECT DISTINCT f.rel, b.cls FROM bases b JOIN files f ON f.id = b.file"
1597
+ " WHERE b.tail = ?", (tail,),
1598
+ )
1599
+ ]
1600
+
1601
+ def definitions(self, fulls: Iterable[str]) -> dict[str, tuple[str, dict[str, Any]]]:
1602
+ """``definition`` of many names in a few queries: the found ones, by name."""
1603
+ found: dict[str, tuple[str, dict[str, Any]]] = {}
1604
+ wanted = sorted(set(fulls))
1605
+ for start in range(0, len(wanted), 500):
1606
+ chunk = wanted[start:start + 500]
1607
+ marks = ", ".join("?" * len(chunk))
1608
+ # per name, the first file and within it the last row, as ``definition`` picks
1609
+ for full, rel, raw in self.db.execute(
1610
+ "SELECT d.full, f.rel, d.json FROM defs d JOIN files f ON f.id = d.file"
1611
+ f" WHERE d.full IN ({marks}) ORDER BY d.full, f.rel, d.rowid DESC",
1612
+ chunk,
1613
+ ):
1614
+ if full not in found:
1615
+ found[full] = (rel, _row(raw, DEF_TYPES))
1616
+ return found
1617
+
1618
+ def lines(self, rel: str) -> list[int]:
1619
+ """The ``def`` or ``class`` line of every definition in ``rel``, sorted."""
1620
+ return sorted(
1621
+ _row(raw, DEF_TYPES)["line"]
1622
+ for (raw,) in self.db.execute(
1623
+ "SELECT d.json FROM defs d JOIN files f ON f.id = d.file WHERE f.rel = ?", (rel,)
1624
+ )
1625
+ )
1626
+
1627
+ def _export(self, name: str) -> str | None:
1628
+ """What the module-level name ``name`` finally stands for when another module binds
1629
+ it there (an import), or None when its last binding is its own definition."""
1630
+ row = self.db.execute(
1631
+ "SELECT b.target, f.rel FROM binds b JOIN files f ON f.id = b.file"
1632
+ " WHERE b.name = ? AND b.scope = '' ORDER BY b.line DESC, b.rowid DESC LIMIT 1",
1633
+ (name,),
1634
+ ).fetchone()
1635
+ if row is None or not isinstance(row[0], str) or not isinstance(row[1], str):
1636
+ return None
1637
+ target = self.located(row[1], row[0])
1638
+ return target if target != name else None
1639
+
1640
+ def _binds_at(
1641
+ self, rel: str, scope: str, name: str, before: int = 0
1642
+ ) -> dict[str, tuple[bool, str, int]]:
1643
+ """Scope -> (seen from nested scopes, target, line) of the latest binding of ``name`` in
1644
+ ``scope``, each scope enclosing it and the module (""). With ``before``, in ``scope``
1645
+ itself (and the module, for a module-level or top-level class body) only a binding from
1646
+ before that line counts: that code runs top to bottom. SQLite picks the one row per
1647
+ scope through the (file, alias, scope, line) index, so a lookup keeps the scope's depth
1648
+ in rows, not the file's bindings; the cache is emptied past ``MAX_CACHED`` lookups."""
1649
+ key = (rel, scope, name, before)
1650
+ if key not in self._binds:
1651
+ chain, s = {""}, scope
1652
+ while s:
1653
+ chain.add(s)
1654
+ s = s.rpartition(".")[0]
1655
+ cut = ({scope, ""} if "." not in scope else {scope}) if before else set()
1656
+ found: dict[str, tuple[bool, str, int]] = {}
1657
+ for batch in _batches(chain):
1658
+ marks = ", ".join("?" * len(batch))
1659
+ cuts = ", ".join("?" * len(cut & set(batch)))
1660
+ for s, deep, target, line in self.db.execute(
1661
+ "SELECT b.scope, b.deep, b.target, MAX(b.line) FROM binds b"
1662
+ " JOIN files f ON f.id = b.file WHERE f.rel = ? AND b.alias = ?"
1663
+ f" AND b.scope IN ({marks})"
1664
+ + (f" AND (b.scope NOT IN ({cuts}) OR b.line < ?)" if cuts else "")
1665
+ + " GROUP BY b.scope",
1666
+ (rel, name, *batch, *(sorted(cut & set(batch)) + [before] if cuts else [])),
1667
+ ):
1668
+ if isinstance(s, str) and isinstance(target, str) and type(line) is int:
1669
+ found[s] = (bool(deep), target, line)
1670
+ if len(self._binds) >= MAX_CACHED:
1671
+ self._binds.clear()
1672
+ self._binds[key] = found
1673
+ return self._binds[key]
1674
+
1675
+ def bound_line(self, rel: str, scope: str, name: str, before: int) -> int:
1676
+ """The line of the latest import before ``before`` that binds ``name`` in ``scope``
1677
+ itself, or 0."""
1678
+ hit = self._binds_at(rel, scope, name, before).get(scope) if scope else None
1679
+ return hit[2] if hit is not None else 0
1680
+
1681
+ def binding(self, rel: str, scope: str, name: str, before: int = 0) -> str | None:
1682
+ """What ``name`` stands for in ``scope`` of ``rel``: the import in the innermost
1683
+ enclosing function that binds it, else the module-level binding. With ``before`` (code
1684
+ at module level or in a class body, which runs top to bottom), the binding of that
1685
+ moment: only one from before that line counts in its own scope."""
1686
+ found, here = self._binds_at(rel, scope, name, before), True
1687
+ while scope:
1688
+ hit = found.get(scope)
1689
+ if hit is not None and (hit[0] or here):
1690
+ return self.located(rel, hit[1])
1691
+ scope, here = scope.rpartition(".")[0], False
1692
+ module = found.get("")
1693
+ return self.located(rel, module[1]) if module is not None else None
1694
+
1695
+ def located(self, rel: str, target: str) -> str:
1696
+ """``target``, an import path written in ``rel``, as the index names it. Module names
1697
+ start at the repository root; code in a nested project (``apps/api`` with its own
1698
+ ``pyproject.toml``) imports from its own folder, or from its ``src/`` on the path, so
1699
+ an import path the index does not know is looked up there."""
1700
+ if not self.roots:
1701
+ return target
1702
+ if rel not in self._root:
1703
+ if len(self._root) >= MAX_CACHED:
1704
+ self._root.clear()
1705
+ self._root[rel] = root_of(rel, self.roots)
1706
+ root = self._root[rel]
1707
+ if not root or target.count(".") >= MAX_IMPORT_PARTS or self._known_depth(target):
1708
+ return target
1709
+ base = root.replace("/", ".")
1710
+ for prefix in (base, f"{base}.src"):
1711
+ # The candidate counts only when it names something inside the project: a module
1712
+ # or definition below the prefix, not merely the prefix's own folders.
1713
+ if self._known_depth(f"{prefix}.{target}") > prefix.count(".") + 1:
1714
+ return f"{prefix}.{target}"
1715
+ return target
1716
+
1717
+ def _known_depth(self, name: str) -> int:
1718
+ """How many leading parts of ``name`` the index knows: the longest prefix that is a
1719
+ definition or a module-level binding (an import a package re-exports), or a module
1720
+ holding one (0 when not even the first part is). At most ``MAX_IMPORT_PARTS`` parts are
1721
+ looked at, so a crafted dotted name costs a bounded number of lookups."""
1722
+ parts = name.split(".")[:MAX_IMPORT_PARTS]
1723
+ for depth in range(len(parts), 0, -1):
1724
+ prefix = ".".join(parts[:depth])
1725
+ if prefix not in self._known:
1726
+ if len(self._known) >= MAX_CACHED:
1727
+ self._known.clear()
1728
+ span = (prefix, f"{prefix}.", f"{prefix}/")
1729
+ self._known[prefix] = self.db.execute(
1730
+ "SELECT 1 FROM defs WHERE full = ? OR (full > ? AND full < ?)"
1731
+ " UNION ALL SELECT 1 FROM binds WHERE scope = '' AND"
1732
+ " (name = ? OR (name > ? AND name < ?)) LIMIT 1",
1733
+ (*span, *span),
1734
+ ).fetchone() is not None
1735
+ if self._known[prefix]:
1736
+ return depth
1737
+ return 0
1738
+
1739
+ def is_test(self, rel: str) -> bool:
1740
+ if rel not in self._tests:
1741
+ row = self.db.execute("SELECT test FROM files WHERE rel = ?", (rel,)).fetchone()
1742
+ self._tests[rel] = bool(row and row[0])
1743
+ return self._tests[rel]
1744
+
1745
+ def resolve(self, full: str) -> str:
1746
+ """``full`` with re-exports followed: ``app.services.create`` becomes
1747
+ ``app.services.orders.create`` when the package re-exports it. A name inside a
1748
+ re-exported class (``pkg.Model.save``) is followed through its prefix."""
1749
+ for _ in range(MAX_HOPS):
1750
+ # The module's binding first: an import after a definition of the same name wins.
1751
+ moved = self._export(full)
1752
+ if moved is not None:
1753
+ full = moved
1754
+ continue
1755
+ if self.definition(full) is not None:
1756
+ return full
1757
+ head, _, tail = full.rpartition(".")
1758
+ if not head:
1759
+ return full
1760
+ prefix = self._resolve_prefix(head)
1761
+ if prefix == head:
1762
+ return full
1763
+ full = f"{prefix}.{tail}"
1764
+ return full
1765
+
1766
+ def _resolve_prefix(self, name: str, depth: int = 0) -> str:
1767
+ """``name`` with re-exports followed, on itself or on a shorter prefix: ``pkg.api.Service``
1768
+ is ``pkg.implementation.Service`` when ``pkg`` re-exports ``implementation as api``."""
1769
+ for _ in range(MAX_HOPS):
1770
+ moved = self._export(name)
1771
+ if moved is None:
1772
+ if self.definition(name) is not None:
1773
+ return name
1774
+ break
1775
+ name = moved
1776
+ head, _, tail = name.rpartition(".")
1777
+ if not head or depth >= MAX_HOPS:
1778
+ return name
1779
+ prefix = self._resolve_prefix(head, depth + 1)
1780
+ return name if prefix == head else f"{prefix}.{tail}"
1781
+
1782
+ #: The definitions a name can mean: its last part is the tail, and it is the full name or a
1783
+ #: dotted suffix of it.
1784
+ _MATCHING = " FROM defs WHERE tail = ? AND (full = ? OR substr(full, -?) = ?)"
1785
+
1786
+ def _matching(self, name: str, kind: str | None = None) -> tuple[str, list[Any]]:
1787
+ """The ``WHERE`` clause of the definitions ``name`` can mean, of ``kind`` if given, and
1788
+ its parameters."""
1789
+ suffix = f".{name}"
1790
+ params: list[Any] = [name.rsplit(".", 1)[-1], name, len(suffix), suffix]
1791
+ if kind is None:
1792
+ return self._MATCHING, params
1793
+ return f"{self._MATCHING} AND json_extract(json, '$[1]') = ?", [*params, kind]
1794
+
1795
+ def find(
1796
+ self, name: str, limit: int = MAX_MATCHES, kind: str | None = None
1797
+ ) -> list[str]:
1798
+ """Full names of the definitions ``name`` can mean: a full dotted name, a qualified
1799
+ name (``Class.method``) or a bare name; at most ``limit`` of them, in order, whose
1800
+ stored definitions add up to at most ``MAX_MATCH_BYTES`` (the first always counts).
1801
+ ``find_cut`` says whether the size stopped the list; ``count_matches`` says how many
1802
+ there are. With ``kind``, only definitions of that kind count, before the limit."""
1803
+ self.find_cut = False
1804
+ resolved = self.resolve(name)
1805
+ definition = self.definition(resolved)
1806
+ if definition is not None and (kind is None or definition[1]["kind"] == kind):
1807
+ return [resolved]
1808
+ where, params = self._matching(name, kind)
1809
+ found: list[str] = []
1810
+ size = 0
1811
+ for full, length in self.db.execute(
1812
+ f"SELECT full, MAX(length(json)){where} GROUP BY full ORDER BY full LIMIT ?",
1813
+ (*params, limit),
1814
+ ):
1815
+ if not isinstance(full, str) or type(length) is not int:
1816
+ continue
1817
+ if found and size + length > MAX_MATCH_BYTES:
1818
+ self.find_cut = True
1819
+ break
1820
+ found.append(full)
1821
+ size += length
1822
+ return found
1823
+
1824
+ def count_matches(self, name: str, kind: str | None = None) -> int:
1825
+ where, params = self._matching(name, kind)
1826
+ row = self.db.execute(f"SELECT COUNT(DISTINCT full){where}", params).fetchone()
1827
+ return row[0] if row is not None and isinstance(row[0], int) else 0
1828
+
1829
+ def of_kind(self, kind: str, limit: int) -> list[str]:
1830
+ """Full names of the definitions of ``kind`` (``class``, ``function``), in order; at most
1831
+ ``limit``."""
1832
+ return [
1833
+ full for (full,) in self.db.execute(
1834
+ "SELECT DISTINCT full FROM defs WHERE json_extract(json, '$[1]') = ? "
1835
+ "ORDER BY full LIMIT ?",
1836
+ (kind, limit),
1837
+ )
1838
+ if isinstance(full, str)
1839
+ ]
1840
+
1841
+ def kinds(self) -> list[str]:
1842
+ """Every kind of definition the index holds."""
1843
+ return [
1844
+ kind for (kind,) in self.db.execute(
1845
+ "SELECT DISTINCT json_extract(json, '$[1]') FROM defs ORDER BY 1"
1846
+ )
1847
+ if isinstance(kind, str)
1848
+ ]
1849
+
1850
+ def similar(self, name: str, limit: int = 10) -> list[str]:
1851
+ """Defined names whose last part contains the last part of ``name``."""
1852
+ needle = name.rsplit(".", 1)[-1].lower()
1853
+ found = set()
1854
+ for (full,) in self.db.execute("SELECT DISTINCT full FROM defs WHERE instr(lower(tail), ?)",
1855
+ (needle,)):
1856
+ if isinstance(full, str):
1857
+ found.add(full)
1858
+ if len(found) >= max(200, limit):
1859
+ break
1860
+ return sorted(found)[:limit]
1861
+
1862
+ def aliases(self, tails: set[str], limit: int = MAX_ALIASES) -> set[str]:
1863
+ """``tails`` and every local name bound to one of them under another name
1864
+ (``from lib import create as renamed``), followed through re-exports that rename; at
1865
+ most ``limit`` names, read in batches."""
1866
+ found = set(tails)
1867
+ frontier = set(tails)
1868
+ for _ in range(MAX_HOPS):
1869
+ new: set[str] = set()
1870
+ for batch in _batches(frontier):
1871
+ for (alias,) in self.db.execute(
1872
+ "SELECT DISTINCT alias FROM binds WHERE alias != tail"
1873
+ f" AND tail IN ({', '.join('?' * len(batch))})",
1874
+ batch,
1875
+ ):
1876
+ if isinstance(alias, str) and alias not in found and len(found) < limit:
1877
+ found.add(alias)
1878
+ new.add(alias)
1879
+ if not new:
1880
+ break
1881
+ frontier = new
1882
+ return found
1883
+
1884
+ def _named(
1885
+ self, table: str, column: str, types: dict[str, Any], tails: set[str]
1886
+ ) -> Iterator[tuple[str, dict[str, Any]]]:
1887
+ """The rows of ``table`` whose ``column`` is one of ``tails``, decoded one at a time and
1888
+ queried in batches."""
1889
+ for batch in _batches(tails):
1890
+ for rel, text in self.db.execute(
1891
+ f"SELECT f.rel, x.json FROM {table} x JOIN files f ON f.id = x.file"
1892
+ f" WHERE x.{column} IN ({', '.join('?' * len(batch))}) ORDER BY f.rel, x.rowid",
1893
+ batch,
1894
+ ):
1895
+ yield rel, _row(text, types)
1896
+
1897
+ def calls_named(self, tails: set[str]) -> Iterator[tuple[str, dict[str, Any]]]:
1898
+ return self._named("calls", "attr", CALL_TYPES, tails)
1899
+
1900
+ def patches_named(self, tails: set[str]) -> Iterator[tuple[str, dict[str, Any]]]:
1901
+ return self._named("patches", "tail", PATCH_TYPES, tails)
1902
+
1903
+ def uses_named(self, tails: set[str]) -> Iterator[tuple[str, dict[str, Any]]]:
1904
+ return self._named("uses", "attr", USE_TYPES, tails)
1905
+
1906
+ def instance_of(self, rel: str, definition: dict[str, Any]) -> str:
1907
+ """The class a variable defined in ``rel`` is an instance of, from what made it (its
1908
+ annotation or constructor call, resolved where it is written), or "" when that is not a
1909
+ class the index knows. A variable assigned again later is read at its last assignment,
1910
+ as ``definition`` gives it."""
1911
+ made = definition["made"]
1912
+ if not made or self._making >= MAX_HOPS:
1913
+ return ""
1914
+ scope = definition["name"].rpartition(".")[0]
1915
+ self._making += 1
1916
+ try:
1917
+ target = self.call_target(
1918
+ rel,
1919
+ {
1920
+ "callee": made, "recv": "", "attr": made.rsplit(".", 1)[-1], "scope": scope,
1921
+ "cls": scope, "line": definition["line"], "local": False, "glob": False,
1922
+ },
1923
+ )
1924
+ finally:
1925
+ self._making -= 1
1926
+ found = self.definition(target) if target else None
1927
+ return target if found is not None and found[1]["kind"] == "class" else ""
1928
+
1929
+ def call_target(self, rel: str, call: dict[str, Any]) -> str:
1930
+ """The full name a call resolves to, or "" when the receiver's type is unknown."""
1931
+ callee = call["callee"]
1932
+ if not callee and call["recv"]:
1933
+ made = self.call_target(rel, {**call, "callee": call["recv"], "recv": ""})
1934
+ found = self.definition(made) if made else None
1935
+ if found is not None and found[1]["kind"] == "class":
1936
+ member = self.resolve(f"{made}.{call['attr']}")
1937
+ # Not defined on the class (inherited, or set at run time): by name only.
1938
+ return member if self.definition(member) is not None else ""
1939
+ return ""
1940
+ if not callee:
1941
+ return ""
1942
+ head, _, rest = callee.partition(".")
1943
+ module = module_name(rel, self.src_package)
1944
+ if head in ("self", "cls") and call["cls"] and rest and "." not in rest:
1945
+ member = self.resolve(f"{module}.{call['cls']}.{rest}")
1946
+ # Not defined on the class (inherited, or set at run time): unknown, so by name only.
1947
+ return member if self.definition(member) is not None else ""
1948
+ if call["local"]:
1949
+ return "" # a parameter or local of the same name, not the file's binding
1950
+ # Code at module level runs top to bottom: it means the binding of its own moment.
1951
+ before = 0 if _running_scope(call["scope"]) else call["line"]
1952
+ local: str | None = None
1953
+ if call["cls"] and call["scope"] == call["cls"]:
1954
+ # In the class body itself (not its methods) the class's latest binding of the name
1955
+ # before the call comes first, a definition or an import: the body runs top to
1956
+ # bottom, so a later one does not count yet.
1957
+ before = call["line"]
1958
+ member = f"{module}.{call['cls']}.{head}"
1959
+ found = self.definition(member)
1960
+ defined = (
1961
+ found[1]["line"] if found is not None and found[0] == rel
1962
+ and found[1]["line"] < before else 0
1963
+ )
1964
+ imported = self.bound_line(rel, call["scope"], head, before)
1965
+ if defined and not defined < imported < before:
1966
+ if not rest:
1967
+ return self.resolve(member)
1968
+ # `settings = Settings()` in the body, then `settings.TABLE`: what follows the
1969
+ # class's own variable goes through the same walk as a module's.
1970
+ local = member
1971
+ if local is None:
1972
+ local = (
1973
+ self.binding(rel, "", head) if call["glob"]
1974
+ else self.binding(rel, call["scope"], head, before)
1975
+ )
1976
+ if local is None:
1977
+ return ""
1978
+ on_class = False
1979
+ if rest:
1980
+ # `svc = make(); svc.save()`, or `state.svc.save()` with `svc` a variable of the
1981
+ # module `state`: past a variable the type is unknown, unless the variable was made
1982
+ # by its class (`settings = Settings()`, `settings: Settings = ...`): then what
1983
+ # follows is a member of that class.
1984
+ parts = rest.split(".")
1985
+ i, hops = 0, 0
1986
+ while i < len(parts):
1987
+ found = self.definition(self.resolve(".".join([local, *parts[:i]])))
1988
+ if found is not None and found[1]["kind"] == "variable":
1989
+ made = self.instance_of(*found) if hops < MAX_HOPS else ""
1990
+ if not made:
1991
+ return ""
1992
+ local, parts, i, hops, on_class = made, parts[i:], 0, hops + 1, True
1993
+ continue
1994
+ on_class = on_class or (found is not None and found[1]["kind"] == "class")
1995
+ i += 1
1996
+ rest = ".".join(parts)
1997
+ full = f"{local}.{rest}" if rest else local
1998
+ if on_class and self.definition(self.resolve(full)) is None:
1999
+ # `Child.build()` of a member the class does not define (inherited, or set at run
2000
+ # time): unknown, so by name only, as for `self` and a constructed instance.
2001
+ return ""
2002
+ # The file's own definition, chosen as the binding of the call's moment: a later import
2003
+ # of the name (the module's final binding, which `resolve` follows) does not apply yet.
2004
+ if local == (f"{module}.{head}" if module else head) and self.definition(full):
2005
+ return full
2006
+ return self.resolve(full)
2007
+
2008
+ def patch_target(self, rel: str, patch: dict[str, Any]) -> str:
2009
+ """The full name a patch replaces: a string target is already an import path; an
2010
+ object target (``patch.object(orders, "create")``) is resolved through the file."""
2011
+ target = patch["target"]
2012
+ if patch["how"] in ("patch", "monkeypatch.setattr"):
2013
+ return self.resolve(self.located(rel, target))
2014
+ if patch["local"]:
2015
+ return "" # `def test(lib): patch.object(lib, ...)`: the parameter, not the import
2016
+ head, _, rest = target.partition(".")
2017
+ if patch["glob"]:
2018
+ local = self.binding(rel, "", head)
2019
+ else:
2020
+ # At module level the receiver is the binding of the patch's own moment.
2021
+ before = 0 if _running_scope(patch["scope"]) else patch["line"]
2022
+ local = self.binding(rel, patch["scope"], head, before)
2023
+ return self.resolve(f"{local}.{rest}") if local else ""
2024
+
2025
+ def patched_at(self, rel: str, line: int, tails: set[str], wanted: set[str]) -> bool:
2026
+ """Whether line ``line`` of ``rel`` patches one of ``wanted``: only that line's rows are
2027
+ read, through the (file, line) index, so a query keeps no set of every patch site."""
2028
+ for batch in _batches(tails):
2029
+ rows = self.db.execute(
2030
+ "SELECT x.json FROM patches x JOIN files f ON f.id = x.file"
2031
+ f" WHERE f.rel = ? AND x.line = ? AND x.tail IN ({', '.join('?' * len(batch))})",
2032
+ (rel, line, *batch),
2033
+ )
2034
+ if any(self.patch_target(rel, _row(text, PATCH_TYPES)) in wanted for (text,) in rows):
2035
+ return True
2036
+ return False
2037
+
2038
+
2039
+ #: How many names one diagnostic lists before it says how many more there are.
2040
+ MAX_NAMED = 10
2041
+
2042
+
2043
+ def signature_shape(definition: dict[str, Any], bound: bool) -> dict[str, Any]:
2044
+ """What ``shape_problems`` needs of a signature, built once: with ``bound``, the first
2045
+ parameter is passed implicitly. ``index`` maps each positional name to its position."""
2046
+ skip = 1 if bound and definition["pos"] else 0
2047
+ pos = definition["pos"][skip:]
2048
+ return {
2049
+ "count": len(pos), "posonly": max(definition["posonly"] - skip, 0),
2050
+ "index": {name: i for i, name in enumerate(pos)},
2051
+ "required": max(len(pos) - definition["defaults"], 0),
2052
+ "kwonly": set(definition["kwonly"]), "kwreq": set(definition["kwreq"]),
2053
+ }
2054
+
2055
+
2056
+ class _Shapes:
2057
+ """The signature shapes of one query, (target, through ``__init__``, bound) -> shape: each
2058
+ built once, not once a call. The kept ones hold at most ``budget`` parameters together, and
2059
+ the query builds at most ``work`` parameters in all: a shape that does not fit is built
2060
+ again when needed, until the work is spent, after which ``get`` returns None (the call is
2061
+ listed, its shape not checked). Nothing kept is dropped, so alternating calls cannot make
2062
+ every call rebuild a shape."""
2063
+
2064
+ def __init__(self, budget: int, work: int) -> None:
2065
+ self.budget, self.work = budget, work
2066
+ self.kept: dict[tuple[str, bool, bool], dict[str, Any]] = {}
2067
+ self.size = 0
2068
+
2069
+ def get(
2070
+ self, key: tuple[str, bool, bool], definition: dict[str, Any], bound: bool
2071
+ ) -> dict[str, Any] | None:
2072
+ if key in self.kept:
2073
+ return self.kept[key]
2074
+ size = len(definition["pos"]) + len(definition["kwonly"])
2075
+ if size > self.work:
2076
+ return None
2077
+ self.work -= size
2078
+ shape = signature_shape(definition, bound)
2079
+ if self.size + size <= self.budget:
2080
+ self.kept[key] = shape
2081
+ self.size += size
2082
+ return shape
2083
+
2084
+
2085
+ def _listing(names: list[str], total: int) -> str:
2086
+ text = ", ".join(names[:MAX_NAMED])
2087
+ return f"{text} and {total - MAX_NAMED} more" if total > MAX_NAMED else text
2088
+
2089
+
2090
+ def shape_problems(
2091
+ definition: dict[str, Any], call: dict[str, Any], bound: bool,
2092
+ shape: dict[str, Any] | None = None,
2093
+ ) -> list[str]:
2094
+ """What is wrong with how ``call`` passes arguments to ``definition`` (empty when it fits or
2095
+ when ``*args``/``**kwargs`` at the call site make it unknowable). ``shape`` is
2096
+ ``signature_shape(definition, bound)``, passed when one query checks many calls: the check
2097
+ then costs the call's size, not the signature's, and each message names at most
2098
+ ``MAX_NAMED`` names."""
2099
+ if call["star"] or call["dstar"] or definition["kind"] in ("class", "variable", "property"):
2100
+ return []
2101
+ if shape is None:
2102
+ shape = signature_shape(definition, bound)
2103
+ count, posonly, index = shape["count"], shape["posonly"], shape["index"]
2104
+ passed = call["pos"]
2105
+ problems = []
2106
+ if passed > count and not definition["star"]:
2107
+ kwonly = definition["kwonly"]
2108
+ hint = f"; keyword-only: {_listing(kwonly, len(kwonly))}" if kwonly else ""
2109
+ problems.append(f"{passed - count} positional argument(s) too many{hint}")
2110
+ # A positional-only name given as a keyword lands in `**kwargs`, or is an error without it.
2111
+ if not definition["dstar"]:
2112
+ misplaced = [k for k in call["kw"] if index.get(k, posonly) < posonly]
2113
+ if misplaced:
2114
+ listed = _listing(misplaced, len(misplaced))
2115
+ problems.append(f"positional-only passed by keyword: {listed}")
2116
+ unknown = [k for k in call["kw"] if k not in index and k not in shape["kwonly"]]
2117
+ if unknown:
2118
+ problems.append(f"unknown keyword(s): {_listing(unknown, len(unknown))}")
2119
+ twice = [k for k in call["kw"] if posonly <= index.get(k, passed) < passed]
2120
+ if twice:
2121
+ problems.append(f"multiple values for: {_listing(twice, len(twice))}")
2122
+ given = set(call["kw"])
2123
+ absent = len(shape["kwreq"]) - sum(1 for k in given if k in shape["kwreq"])
2124
+ if absent:
2125
+ # Scanning in signature order stops at the names shown: past the call's own keywords,
2126
+ # the first few absent ones are found within that many more steps.
2127
+ first = list(islice((k for k in definition["kwreq"] if k not in given), MAX_NAMED))
2128
+ problems.append(f"missing keyword-only: {_listing(first, absent)}")
2129
+ # Required positions the call fills neither by position nor by keyword.
2130
+ required, start = shape["required"], max(passed, posonly)
2131
+ named = sum(1 for k in given if start <= index.get(k, -1) < required)
2132
+ unfilled = max(required - passed, 0) - named
2133
+ if unfilled > 0:
2134
+ problems.append(f"{unfilled} required positional argument(s) missing")
2135
+ return problems
2136
+
2137
+
2138
+ def _bound(index: Index, rel: str, call: dict[str, Any], target: str) -> bool:
2139
+ """Whether the call passes the first parameter implicitly: a method called on an instance
2140
+ (``self.save()``, ``order.save()``) or any classmethod, not ``Order.save(order)``."""
2141
+ found = index.definition(target)
2142
+ kind = found[1]["kind"] if found else ""
2143
+ if kind == "classmethod":
2144
+ return True
2145
+ if kind != "method" or (call["callee"] and "." not in call["callee"]):
2146
+ return False # `create(1, 2)` in the class body: a plain function call, no `self`
2147
+ receiver = call["callee"].rpartition(".")[0]
2148
+ if not receiver:
2149
+ return True # an instance made in place: `Service(repo).place(...)`
2150
+ if receiver == "cls":
2151
+ return False # `cls.save(instance, ...)` in a classmethod: the class, not an instance
2152
+ owner = target.rpartition(".")[0]
2153
+ return index.call_target(rel, {**call, "callee": receiver, "recv": ""}) != owner
2154
+
2155
+
2156
+ #: How many sites of each kind ``references`` keeps; past it they are counted, not kept, so a name
2157
+ #: called hundreds of thousands of times costs no more memory than this many rows.
2158
+ MAX_LISTED = 10_000
2159
+ #: How many bytes of text the sites one ``references`` query keeps add up to at most, across its
2160
+ #: lists; past it they are counted, not kept, so long scopes or names cannot multiply the rows.
2161
+ MAX_LISTED_BYTES = 10_000_000
2162
+ #: How many candidate rows (calls, value uses and patches of the name and its aliases) one query
2163
+ #: resolves at most: each can cost a few lookups, so this bounds the work and the lookup cache.
2164
+ MAX_EXAMINED = 100_000
2165
+ #: How many variables assigned from a target ``references`` follows, one level each: they share
2166
+ #: the rows-read budget, so this bounds the extra queries, not the work.
2167
+ MAX_FOLLOWED = 20
2168
+ #: How many binding lookups ``Index`` keeps cached before it empties the cache; ``references``
2169
+ #: keeps as many per-line patch answers.
2170
+ MAX_CACHED = 10_000
2171
+ #: How many bytes of stored definitions ``Index.definition`` keeps decoded before it empties its
2172
+ #: cache.
2173
+ MAX_CACHED_DEF_BYTES = 8_000_000
2174
+ #: How many dotted parts of an import path the project-root lookup examines: real module paths
2175
+ #: have a handful, and a longer one is left as written.
2176
+ MAX_IMPORT_PARTS = 32
2177
+ #: How many parameters the signature shapes one ``references`` query keeps add up to at most; past
2178
+ #: it they are dropped and rebuilt as needed, so many broad signatures of one name cost no more.
2179
+ MAX_SHAPE_PARAMS = 100_000
2180
+ #: How many parameters one ``references`` query reads into signature shapes in all, rebuilt ones
2181
+ #: included; past it, calls are listed without a shape check (``counts["unchecked"]``).
2182
+ MAX_SHAPE_WORK = 2_000_000
2183
+
2184
+
2185
+ def references(
2186
+ index: Index, targets: list[str], keep: Callable[[dict[str, Any]], bool] | None = None,
2187
+ limit: int = MAX_LISTED, budget: list[int] | None = None, follow: bool = False,
2188
+ shape_of: str = "", unbound_functions: bool = False,
2189
+ ) -> dict[str, Any]:
2190
+ """Exact call sites of ``targets`` with their shape and problems, the patch sites, the places
2191
+ the name is used as a value (a line already listed as a patch is not), and the by-name
2192
+ candidates: calls of the same attribute on a receiver whose type is unknown. ``keep`` filters
2193
+ the sites (tests or code); ``counts`` holds the totals of the kept sites, of which each list
2194
+ holds at most ``limit``. ``budget`` (one number in a list) is the rows left to read, shared
2195
+ by callers that look up several names in turn. With ``follow``, a module or class variable
2196
+ assigned straight from a target (``TABLE = settings.TABLE``) is followed one level: its
2197
+ calls, patches and uses are listed too, each with ``via`` naming it. ``shape_of`` (set when
2198
+ following) is the definition whose signature checks the calls found: a call of a method
2199
+ through an alias binds ``self`` or not depending on what was assigned, so it is not checked,
2200
+ nor (with ``unbound_functions``, an alias in a class body) a call of a function."""
2201
+ wanted = set(targets)
2202
+ tails = {t.rsplit(".", 1)[-1] for t in targets}
2203
+ names = index.aliases(tails)
2204
+ lists: dict[str, list[dict[str, Any]]] = {
2205
+ "calls": [], "candidates": [], "patches": [], "uses": [],
2206
+ }
2207
+ shapes = _Shapes(MAX_SHAPE_PARAMS, MAX_SHAPE_WORK)
2208
+ # (file, line) -> whether that line patches the name: one read a line, not one a use.
2209
+ patched: dict[tuple[str, int], bool] = {}
2210
+ counts = dict.fromkeys(
2211
+ ("calls", "calls_in_tests", "problems", "unchecked", "candidates", "patches", "uses"), 0
2212
+ )
2213
+ # The variables to follow: full name -> (the name it is written under, the target it holds,
2214
+ # whether it is written in a class body, its file, the line of its last assignment).
2215
+ aliases: dict[str, tuple[str, str, bool, str, int]] = {}
2216
+ # Each such variable -> the lines of its assignments from a wanted name, the last or not.
2217
+ holding: dict[str, set[tuple[int, int]]] = {}
2218
+ skipped = False # an alias past ``MAX_FOLLOWED``: the lists are then not complete
2219
+
2220
+ kept = [0]
2221
+
2222
+ def keep_row(kind: str, row: dict[str, Any]) -> None:
2223
+ size = _text_bytes(row)
2224
+ if len(lists[kind]) < limit and kept[0] + size <= MAX_LISTED_BYTES:
2225
+ lists[kind].append(row)
2226
+ kept[0] += size
2227
+
2228
+ def add(kind: str, row: dict[str, Any]) -> bool:
2229
+ if keep is not None and not keep(row):
2230
+ return False
2231
+ counts[kind] += 1
2232
+ keep_row(kind, row)
2233
+ return True
2234
+
2235
+ if budget is None:
2236
+ budget = [MAX_EXAMINED]
2237
+
2238
+ def examine(rows: Iterator[tuple[str, dict[str, Any]]]) -> Iterator[tuple[str, dict[str, Any]]]:
2239
+ for row in rows:
2240
+ if budget[0] <= 0:
2241
+ return
2242
+ budget[0] -= 1
2243
+ yield row
2244
+
2245
+ for rel, patch in examine(index.patches_named(names)):
2246
+ if index.patch_target(rel, patch) in wanted:
2247
+ add(
2248
+ "patches",
2249
+ {
2250
+ "file": rel, "line": patch["line"], "how": patch["how"],
2251
+ "target": patch["target"], "scope": patch["scope"],
2252
+ "test": index.is_test(rel),
2253
+ },
2254
+ )
2255
+ for rel, call in examine(index.calls_named(names)):
2256
+ target = index.call_target(rel, call)
2257
+ base = {
2258
+ "file": rel, "line": call["line"], "col": call["col"], "scope": call["scope"],
2259
+ "test": index.is_test(rel), "shape": _shape_text(call),
2260
+ }
2261
+ found = index.definition(target) if target in wanted else None
2262
+ if found is not None:
2263
+ owner = shape_of or target
2264
+ checked = index.definition(owner) if shape_of else found
2265
+ unknown = ("method", "function") if unbound_functions else ("method",)
2266
+ if checked is None or (shape_of and checked[1]["kind"] in unknown):
2267
+ problems, shape = [], None
2268
+ else:
2269
+ definition, bound = checked[1], _bound(index, rel, call, owner)
2270
+ if definition["kind"] == "class":
2271
+ init = index.definition(f"{owner}.__init__")
2272
+ definition, bound = (init[1], True) if init else (definition, False)
2273
+ shape = shapes.get(
2274
+ (owner, definition is not checked[1], bound), definition, bound
2275
+ )
2276
+ problems = [] if shape is None else shape_problems(definition, call, bound, shape)
2277
+ if add("calls", {**base, "target": target, "problems": problems}):
2278
+ counts["unchecked"] += shape is None
2279
+ counts["calls_in_tests"] += base["test"]
2280
+ counts["problems"] += bool(problems)
2281
+ elif (
2282
+ not target and call["attr"] in tails
2283
+ and (call["callee"] == "" or "." in call["callee"])
2284
+ ):
2285
+ add("candidates", {**base, "callee": call["callee"] or f"(…).{call['attr']}"})
2286
+ for rel, use in examine(index.uses_named(names)):
2287
+ # A patch is also an argument (`monkeypatch.setattr(x, "y", fake)` passes `fake`): only
2288
+ # patch sites are listed as patches.
2289
+ as_call = {
2290
+ "callee": use["name"], "recv": "", "cls": use["cls"], "attr": use["attr"],
2291
+ "local": False, "glob": use["glob"], "scope": use["scope"], "line": use["line"],
2292
+ }
2293
+ hit = index.call_target(rel, as_call)
2294
+ if hit not in wanted:
2295
+ continue
2296
+ spot = (rel, use["line"])
2297
+ if spot not in patched:
2298
+ if len(patched) >= MAX_CACHED:
2299
+ patched.clear()
2300
+ patched[spot] = index.patched_at(rel, use["line"], names, wanted)
2301
+ if not patched[spot]:
2302
+ row = {
2303
+ "file": rel, "line": use["line"], "col": use["col"], "how": use["how"],
2304
+ "within": use["within"], "scope": use["scope"], "test": index.is_test(rel),
2305
+ }
2306
+ add("uses", row)
2307
+ # Found whether or not ``keep`` lists the assignment itself: `--tests` still wants
2308
+ # the tests that use a production module's alias.
2309
+ if (
2310
+ follow and use["how"] == "assign"
2311
+ and use["within"].isidentifier() and use["scope"] in ("", use["cls"])
2312
+ ):
2313
+ module = module_name(rel, index.src_package)
2314
+ alias = ".".join(p for p in (module, use["scope"], use["within"]) if p)
2315
+ defined = index.definition(alias)
2316
+ if alias not in wanted and defined is not None and defined[0] == rel:
2317
+ # Bounded by the uses examined (``budget``).
2318
+ holding.setdefault(alias, set()).add((use["line"], use["col"]))
2319
+ # Only the variable's last assignment, as Python binds it: `A = VALUE` then
2320
+ # `A = 3` leaves `A` meaning 3.
2321
+ if (
2322
+ alias not in wanted and alias not in aliases and defined is not None
2323
+ and defined[0] == rel
2324
+ and _span(defined[1])[0] <= (use["line"], use["col"]) <= _span(defined[1])[1]
2325
+ ):
2326
+ if len(aliases) < MAX_FOLLOWED:
2327
+ aliases[alias] = (
2328
+ use["within"], hit, bool(use["scope"]), rel, defined[1]["line"]
2329
+ )
2330
+ else:
2331
+ skipped = True
2332
+ cut = skipped
2333
+ for alias, (short, original, in_class, at, assigned) in aliases.items():
2334
+ spans = index.spans(at, alias)
2335
+ held_at = holding.get(alias, set())
2336
+
2337
+ def held(
2338
+ row: dict[str, Any], at: str = at, assigned: int = assigned,
2339
+ spans: list[tuple[tuple[int, int], tuple[int, int]]] = spans,
2340
+ held_at: set[tuple[int, int]] = held_at,
2341
+ ) -> bool:
2342
+ # Module code and class bodies before the last assignment run with whatever the
2343
+ # variable held then: the latest assignment that ended before the site, by line and
2344
+ # column (a right-hand side reads the binding from before its own; a read after a
2345
+ # `;` on the same line reads the new one), must be one from the target. A function
2346
+ # body runs later, whatever its line. A patch (a row with neither `problems` nor
2347
+ # `within`) looks its target up when it is applied, so it is kept.
2348
+ site = "problems" in row or "within" in row
2349
+ if (
2350
+ site and row["file"] == at and row["line"] < assigned
2351
+ and _import_time(index, at, row)
2352
+ ):
2353
+ site_at = (row["line"], row["col"])
2354
+ last = next((s for s in reversed(spans) if s[1] <= site_at), None)
2355
+ if last is None or not any(last[0] <= at_ <= last[1] for at_ in held_at):
2356
+ return False
2357
+ return keep is None or keep(row)
2358
+
2359
+ # A call of the alias (`ALIAS = send; ALIAS()`) is checked against the target's own
2360
+ # signature, not the variable's empty one.
2361
+ # In a class body a function becomes a method of the class's instances (`emit = send`,
2362
+ # then `Client().emit()` passes `self`), so whether `self` is bound is not known.
2363
+ found = references(
2364
+ index, [alias], held, limit, budget, shape_of=original, unbound_functions=in_class
2365
+ )
2366
+ cut = cut or found["truncated"]
2367
+ # The alias's totals, not only the rows it kept: its lists are cut at ``limit`` too.
2368
+ for key in ("calls", "calls_in_tests", "problems", "unchecked", "patches", "uses"):
2369
+ counts[key] += found["counts"][key]
2370
+ for kind in ("calls", "patches", "uses"):
2371
+ for row in found[kind]:
2372
+ keep_row(kind, {**row, "via": short})
2373
+ truncated = cut or len(names) >= MAX_ALIASES or budget[0] <= 0 or any(
2374
+ counts[kind] > len(rows) for kind, rows in lists.items()
2375
+ )
2376
+ return {**lists, "counts": counts, "truncated": truncated}
2377
+
2378
+
2379
+ def _import_time(index: Index, rel: str, row: dict[str, Any]) -> bool:
2380
+ """Whether a site of ``rel`` runs when the module is imported: in module code, a class body
2381
+ or a comprehension there, not inside a function."""
2382
+ parts = row["scope"].split(".") if row["scope"] else []
2383
+ while parts and parts[-1] in _EAGER:
2384
+ parts.pop()
2385
+ if not parts:
2386
+ return True
2387
+ module = module_name(rel, index.src_package)
2388
+ found = index.definition(".".join(p for p in (module, *parts) if p))
2389
+ return found is not None and found[0] == rel and found[1]["kind"] == "class"
2390
+
2391
+
2392
+ def _text_bytes(row: dict[str, Any]) -> int:
2393
+ """The characters of a site row's text: its strings and the strings in its lists."""
2394
+ return sum(
2395
+ len(v) if isinstance(v, str)
2396
+ else sum(len(x) for x in v if isinstance(x, str)) if isinstance(v, list) else 0
2397
+ for v in row.values()
2398
+ )
2399
+
2400
+
2401
+ def _shape_text(call: dict[str, Any]) -> str:
2402
+ parts = []
2403
+ if call["pos"]:
2404
+ parts.append(f"{call['pos']} positional")
2405
+ if call["kw"]:
2406
+ parts.append("kw " + ",".join(call["kw"]))
2407
+ if call["star"]:
2408
+ parts.append("*args")
2409
+ if call["dstar"]:
2410
+ parts.append("**kwargs")
2411
+ return "; ".join(parts) or "no arguments"