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.
- codeterrain/__init__.py +3 -0
- codeterrain/_guard.py +243 -0
- codeterrain/_pytest_dump_plugin.py +253 -0
- codeterrain/_routes_runner.py +119 -0
- codeterrain/budget.py +128 -0
- codeterrain/cache.py +618 -0
- codeterrain/calls_join.py +416 -0
- codeterrain/calls_static.py +1099 -0
- codeterrain/cli.py +1447 -0
- codeterrain/code_static.py +2411 -0
- codeterrain/config.py +331 -0
- codeterrain/executables.py +130 -0
- codeterrain/fixtures_order.py +77 -0
- codeterrain/fixtures_static.py +501 -0
- codeterrain/hookspecs.py +25 -0
- codeterrain/impact.py +725 -0
- codeterrain/languages.py +293 -0
- codeterrain/plugins/__init__.py +0 -0
- codeterrain/plugins/fastapi_routes.py +193 -0
- codeterrain/plugins/manifest_join.py +107 -0
- codeterrain/protocol.py +138 -0
- codeterrain/routes_view.py +61 -0
- codeterrain/sandbox.py +565 -0
- codeterrain/views.py +198 -0
- codeterrain-0.3.0.dist-info/METADATA +295 -0
- codeterrain-0.3.0.dist-info/RECORD +30 -0
- codeterrain-0.3.0.dist-info/WHEEL +4 -0
- codeterrain-0.3.0.dist-info/entry_points.txt +2 -0
- codeterrain-0.3.0.dist-info/licenses/LICENSE +201 -0
- codeterrain-0.3.0.dist-info/licenses/NOTICE +4 -0
|
@@ -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"
|