diffcone 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- diffcone/__init__.py +13 -0
- diffcone/cache.py +528 -0
- diffcone/check.py +399 -0
- diffcone/classify.py +255 -0
- diffcone/cli.py +886 -0
- diffcone/collect.py +846 -0
- diffcone/cython.py +757 -0
- diffcone/declarations.py +111 -0
- diffcone/discovery/__init__.py +121 -0
- diffcone/discovery/asv_static.py +494 -0
- diffcone/discovery/common.py +130 -0
- diffcone/discovery/pytest_static.py +2680 -0
- diffcone/evidence.py +738 -0
- diffcone/evidence_plan.py +1487 -0
- diffcone/execution.py +1681 -0
- diffcone/indexer/__init__.py +48 -0
- diffcone/indexer/core.py +286 -0
- diffcone/indexer/definitions.py +292 -0
- diffcone/indexer/dynamics.py +406 -0
- diffcone/indexer/facts.py +373 -0
- diffcone/indexer/literals.py +388 -0
- diffcone/indexer/references.py +895 -0
- diffcone/indexer/resolver.py +1001 -0
- diffcone/indexer/scopes.py +276 -0
- diffcone/indexer/state.py +83 -0
- diffcone/indexer/symbols.py +441 -0
- diffcone/indexer/syntax.py +173 -0
- diffcone/manifest.py +166 -0
- diffcone/model.py +191 -0
- diffcone/planner.py +1453 -0
- diffcone/report.py +256 -0
- diffcone/selection.py +133 -0
- diffcone/snapshot.py +739 -0
- diffcone/testing.py +200 -0
- diffcone-0.1.0.dist-info/METADATA +133 -0
- diffcone-0.1.0.dist-info/RECORD +39 -0
- diffcone-0.1.0.dist-info/WHEEL +4 -0
- diffcone-0.1.0.dist-info/entry_points.txt +3 -0
- diffcone-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Scopes the resolver walks (modules, classes, functions), import bindings,
|
|
2
|
+
the nodes a reference resolves to, and relative module names."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import ast
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
from diffcone.indexer.literals import (
|
|
10
|
+
INDEXED,
|
|
11
|
+
_collect_literal_bindings,
|
|
12
|
+
_literal_strings,
|
|
13
|
+
_string_candidates,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class ImportBinding:
|
|
19
|
+
module: str
|
|
20
|
+
attr: str | None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ClassScope:
|
|
25
|
+
id: str
|
|
26
|
+
module: ModuleScope
|
|
27
|
+
enclosing: ClassScope | None = None # the class this one is nested in, if any
|
|
28
|
+
members: dict[str, str] = field(default_factory=dict) # name -> symbol id
|
|
29
|
+
bindings: set[str] = field(default_factory=set)
|
|
30
|
+
# Each base as a dotted name chain, or None for a non-name expression
|
|
31
|
+
# (``Generic[T]``, ``namedtuple(...)``) that the collector visits instead.
|
|
32
|
+
base_chains: list[list[str] | None] = field(default_factory=list)
|
|
33
|
+
# Each base as written, for deciding whether it is external: the name
|
|
34
|
+
# chain, or the subscripted name of ``Generic[T]``-style bases.
|
|
35
|
+
base_names: list[list[str] | None] = field(default_factory=list)
|
|
36
|
+
bases: list[str] = field(default_factory=list) # in-scope base class ids, in order
|
|
37
|
+
complete: bool = True # False when some base is external/dynamic/unresolved
|
|
38
|
+
# No decorators and no class keywords (metaclass=...): nothing but the
|
|
39
|
+
# class body and its bases decides how instances are built.
|
|
40
|
+
plain: bool = True
|
|
41
|
+
# Some base is external, dynamic or unresolved, other than a plain
|
|
42
|
+
# ``object``: attributes may be written by code that is not visible.
|
|
43
|
+
opaque: bool = False
|
|
44
|
+
# Some base is outside the source roots and may call this class's
|
|
45
|
+
# methods (not a builtin or a purely structural typing/abc base).
|
|
46
|
+
external_base: bool = False
|
|
47
|
+
bases_state: int = 0 # 0 pending, 1 resolving, 2 resolved
|
|
48
|
+
mro: list[str] | None = None
|
|
49
|
+
in_mro: bool = False # cycle guard while linearising
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# A top-level ``NAME = <expr>`` or ``NAME: T = <expr>`` that is a variable symbol.
|
|
53
|
+
VariableStatement = ast.Assign | ast.AnnAssign
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class ModuleScope:
|
|
58
|
+
name: str
|
|
59
|
+
path: str
|
|
60
|
+
is_package: bool
|
|
61
|
+
# None for a module whose facts were served by the module cache; the
|
|
62
|
+
# tree is parsed on demand only when the module must be re-resolved.
|
|
63
|
+
tree: ast.Module | None
|
|
64
|
+
imports: dict[str, ImportBinding] = field(default_factory=dict)
|
|
65
|
+
# Other import bindings of a name ``imports`` holds the last one of
|
|
66
|
+
# (``try: from a import f`` / ``except ImportError: from b import f``,
|
|
67
|
+
# ``if``/``else`` imports): which one is live is not known statically,
|
|
68
|
+
# so a reference to the name depends on every one.
|
|
69
|
+
alt_imports: dict[str, list[ImportBinding]] = field(default_factory=dict)
|
|
70
|
+
star_imports: list[str] = field(default_factory=list)
|
|
71
|
+
bindings: set[str] = field(default_factory=set)
|
|
72
|
+
members: dict[str, str] = field(default_factory=dict)
|
|
73
|
+
import_nodes: list[ast.stmt] = field(default_factory=list)
|
|
74
|
+
# Simple top-level assignments that are symbols of their own: name -> id,
|
|
75
|
+
# and the statement each one came from (excluded from the module body hash).
|
|
76
|
+
variables: dict[str, str] = field(default_factory=dict)
|
|
77
|
+
variable_stmts: dict[str, VariableStatement] = field(default_factory=dict)
|
|
78
|
+
# NAME = "lit" / ("a", "b") at module level: string sets a name may hold.
|
|
79
|
+
literal_names: dict[str, tuple[str, ...] | None] = field(default_factory=dict)
|
|
80
|
+
# Names this module mutates in place anywhere (``d[k] = v``,
|
|
81
|
+
# ``d.append(x)``): such a container is not the literal it was assigned.
|
|
82
|
+
mutations: frozenset[str] = frozenset()
|
|
83
|
+
# Module cache bookkeeping: the content key of the file and the digest
|
|
84
|
+
# of everything other modules' resolution may read from this one.
|
|
85
|
+
cache_key: str | None = None
|
|
86
|
+
env_digest: str = ""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class Scope:
|
|
91
|
+
module: ModuleScope
|
|
92
|
+
local_imports: dict[str, ImportBinding] = field(default_factory=dict)
|
|
93
|
+
locals: set[str] = field(default_factory=set)
|
|
94
|
+
self_name: str | None = None
|
|
95
|
+
self_class: str | None = None
|
|
96
|
+
# Function-level ``name = "lit"`` / ``for name in ("a", "b")`` bindings
|
|
97
|
+
# (the string values a name may hold, or None when a binding is not
|
|
98
|
+
# literal) are computed lazily: collecting them walks the whole function,
|
|
99
|
+
# and only functions with a dynamic-name call ever ask. ``literal_node`` is
|
|
100
|
+
# the scope's own body to collect from, ``literal_parent`` the enclosing
|
|
101
|
+
# scope whose bindings are inherited minus ``literal_bound``, and
|
|
102
|
+
# ``literal_extra`` explicit bindings (comprehension variables).
|
|
103
|
+
literal_node: ast.AST | None = None
|
|
104
|
+
literal_parent: Scope | None = None
|
|
105
|
+
literal_bound: frozenset[str] = frozenset()
|
|
106
|
+
literal_extra: dict[str, tuple[str, ...] | None] = field(default_factory=dict)
|
|
107
|
+
_literal_cache: dict[str, tuple[str, ...] | None] | None = field(default=None, repr=False)
|
|
108
|
+
# The enclosing function's own parameters (only in that function's scope,
|
|
109
|
+
# not in nested scopes): name -> positional index or None for keyword-only.
|
|
110
|
+
params: dict[str, int | None] = field(default_factory=dict)
|
|
111
|
+
# Parameters whose default is a module-level variable alias that variable:
|
|
112
|
+
# reads and in-place mutations through the parameter belong to it.
|
|
113
|
+
param_aliases: dict[str, str] = field(default_factory=dict)
|
|
114
|
+
# Names the function body rebinds: a rebound parameter no longer holds
|
|
115
|
+
# what the call sites passed. Only in the function's own scope.
|
|
116
|
+
rebound: frozenset[str] = frozenset()
|
|
117
|
+
# The bound method whose own scope this is (not a nested scope's).
|
|
118
|
+
method: str = ""
|
|
119
|
+
# ``self_name`` names the class (``cls`` of a classmethod), not an instance.
|
|
120
|
+
self_is_class: bool = False
|
|
121
|
+
# A class body's own scope: the members it has defined (``value =
|
|
122
|
+
# property(_get)`` names the method) resolve before module names, and
|
|
123
|
+
# scopes nested in it (lambdas, comprehensions, functions) do not see the
|
|
124
|
+
# class's names at all, as Python's scoping rules say.
|
|
125
|
+
class_members: dict[str, str] = field(default_factory=dict)
|
|
126
|
+
class_level: bool = False
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def literal_names(self) -> dict[str, tuple[str, ...] | None]:
|
|
130
|
+
if self._literal_cache is None:
|
|
131
|
+
names: dict[str, tuple[str, ...] | None] = {}
|
|
132
|
+
if self.literal_parent is not None:
|
|
133
|
+
parent = self.literal_parent.literal_names
|
|
134
|
+
names = {
|
|
135
|
+
k: v
|
|
136
|
+
for k, v in parent.items()
|
|
137
|
+
if k.removesuffix(INDEXED) not in self.literal_bound
|
|
138
|
+
}
|
|
139
|
+
if self.literal_node is not None:
|
|
140
|
+
names.update(
|
|
141
|
+
_collect_literal_bindings(self.literal_node, self.module.literal_names)
|
|
142
|
+
)
|
|
143
|
+
names.update(self.literal_extra)
|
|
144
|
+
names.update({k + INDEXED: None for k in self.literal_extra})
|
|
145
|
+
self._literal_cache = names
|
|
146
|
+
return self._literal_cache
|
|
147
|
+
|
|
148
|
+
def string_candidates(self, expr: ast.expr) -> tuple[str, ...] | None:
|
|
149
|
+
"""Every string ``expr`` may evaluate to, or None when unbounded."""
|
|
150
|
+
return _string_candidates(expr, self.literal_names, self.module.literal_names)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True)
|
|
154
|
+
class Resolved:
|
|
155
|
+
symbol: str
|
|
156
|
+
detail: str = ""
|
|
157
|
+
# Set when the hit was found *after* an external/unknown base in an MRO:
|
|
158
|
+
# the real target may be an override we cannot see, so the name stays
|
|
159
|
+
# bounded (an unresolved attribute record is kept alongside the edge).
|
|
160
|
+
uncertain_attr: str = ""
|
|
161
|
+
# The node is ``self``/``cls`` of the enclosing method: attribute lookups
|
|
162
|
+
# on it dispatch at runtime, so in-scope overrides are recorded too.
|
|
163
|
+
receiver: bool = False
|
|
164
|
+
# Overrides to record alongside the resolved hit (see lookup_in_class):
|
|
165
|
+
# (symbol id, detail) pairs; detail is "" for a method, "attribute:NAME"
|
|
166
|
+
# for a class-attribute rebinding.
|
|
167
|
+
overrides: tuple[tuple[str, str], ...] = ()
|
|
168
|
+
# Modules the name may also denote: ``pkg.retry`` when ``pkg`` binds
|
|
169
|
+
# ``retry`` and has a submodule ``retry`` (see member_symbol_id).
|
|
170
|
+
also: tuple[str, ...] = ()
|
|
171
|
+
# Other symbols or modules the name may be bound to (see
|
|
172
|
+
# ModuleScope.alt_imports): each is a dependency as well.
|
|
173
|
+
alternatives: tuple[str, ...] = ()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(frozen=True)
|
|
177
|
+
class ModuleNode:
|
|
178
|
+
module: str
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(frozen=True)
|
|
182
|
+
class External:
|
|
183
|
+
module: str
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass(frozen=True)
|
|
187
|
+
class Unresolved:
|
|
188
|
+
kind: str
|
|
189
|
+
name: str
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass(frozen=True)
|
|
193
|
+
class Local:
|
|
194
|
+
"""A function-local (or class-local) binding; its value is unknown."""
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
Node = Resolved | ModuleNode | External | Unresolved | Local | None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def resolve_relative_module(current: str, is_package: bool, module: str | None, level: int) -> str:
|
|
201
|
+
"""Absolute module named by ``from <'.' * level><module> import ...`` as
|
|
202
|
+
written inside ``current`` (a package's ``__init__`` counts as the
|
|
203
|
+
package itself)."""
|
|
204
|
+
if level == 0:
|
|
205
|
+
return module or ""
|
|
206
|
+
parts = current.split(".") if current else []
|
|
207
|
+
if not is_package:
|
|
208
|
+
parts = parts[:-1]
|
|
209
|
+
drop = level - 1
|
|
210
|
+
if drop:
|
|
211
|
+
parts = parts[: len(parts) - drop] if drop <= len(parts) else []
|
|
212
|
+
base = ".".join(parts)
|
|
213
|
+
if module:
|
|
214
|
+
return f"{base}.{module}" if base else module
|
|
215
|
+
return base
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def relative_import_escapes(current: str, is_package: bool, level: int) -> bool:
|
|
219
|
+
"""Whether ``from <'.' * level>... import`` climbs above the top-level
|
|
220
|
+
package of ``current``: at runtime that is an error, or the module has
|
|
221
|
+
another name there than its source root gives it (``tests/__init__.py``
|
|
222
|
+
exists but the root is ``tests``). Either way what it imports is unknown."""
|
|
223
|
+
if level == 0:
|
|
224
|
+
return False
|
|
225
|
+
parts = current.split(".") if current else []
|
|
226
|
+
if not is_package:
|
|
227
|
+
parts = parts[:-1]
|
|
228
|
+
return len(parts) < level
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _absolute_module(scope_module: ModuleScope, module: str | None, level: int) -> str:
|
|
232
|
+
return resolve_relative_module(scope_module.name, scope_module.is_package, module, level)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _string_prefix(expr: ast.expr) -> str | None:
|
|
236
|
+
"""The literal prefix of a string built at runtime: an f-string starting
|
|
237
|
+
with text, ``"pkg." + name``, ``"pkg.%s" % name`` or ``"pkg.{}".format(name)``.
|
|
238
|
+
None when the string does not start with a literal."""
|
|
239
|
+
if isinstance(expr, ast.JoinedStr):
|
|
240
|
+
if expr.values and isinstance(expr.values[0], ast.Constant):
|
|
241
|
+
return str(expr.values[0].value) or None
|
|
242
|
+
return None
|
|
243
|
+
if isinstance(expr, ast.BinOp) and isinstance(expr.op, ast.Add):
|
|
244
|
+
left = _literal_strings(expr.left)
|
|
245
|
+
if left is not None and len(left) == 1:
|
|
246
|
+
return left[0] or None
|
|
247
|
+
return _string_prefix(expr.left)
|
|
248
|
+
if isinstance(expr, ast.BinOp) and isinstance(expr.op, ast.Mod):
|
|
249
|
+
if isinstance(expr.left, ast.Constant) and isinstance(expr.left.value, str):
|
|
250
|
+
return expr.left.value.split("%", 1)[0] or None
|
|
251
|
+
return None
|
|
252
|
+
if (
|
|
253
|
+
isinstance(expr, ast.Call)
|
|
254
|
+
and isinstance(expr.func, ast.Attribute)
|
|
255
|
+
and expr.func.attr == "format"
|
|
256
|
+
and isinstance(expr.func.value, ast.Constant)
|
|
257
|
+
and isinstance(expr.func.value.value, str)
|
|
258
|
+
):
|
|
259
|
+
return expr.func.value.value.split("{", 1)[0] or None
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _resolve_relative_name(name: str, package: str, *, prefix: bool = False) -> str | None:
|
|
264
|
+
"""``importlib.resolve_name``: ``..x`` against package ``a.b.c`` is
|
|
265
|
+
``a.b.x``. A prefix keeps its trailing text (``..t.`` -> ``a.b.t.``) and
|
|
266
|
+
``..`` alone becomes ``a.b.``. None when the dots go above the top."""
|
|
267
|
+
if not name.startswith("."):
|
|
268
|
+
return name
|
|
269
|
+
level = len(name) - len(name.lstrip("."))
|
|
270
|
+
bits = package.rsplit(".", level - 1)
|
|
271
|
+
if not package or len(bits) < level:
|
|
272
|
+
return None
|
|
273
|
+
base, rest = bits[0], name[level:]
|
|
274
|
+
if prefix or rest:
|
|
275
|
+
return f"{base}.{rest}"
|
|
276
|
+
return base
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""What the indexer's passes share: the snapshot, the index being built,
|
|
2
|
+
the scopes, and where pass-2 writes go."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
|
|
8
|
+
from diffcone.cython import read as read_cython
|
|
9
|
+
from diffcone.indexer.facts import _Output
|
|
10
|
+
from diffcone.indexer.scopes import ClassScope, ModuleScope
|
|
11
|
+
from diffcone.model import AnalysisError, SourceIndex, Symbol
|
|
12
|
+
from diffcone.snapshot import Snapshot
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class IndexerState:
|
|
16
|
+
"""Shared state of the indexer's passes."""
|
|
17
|
+
|
|
18
|
+
# Submodules per package (pass 2), set by ``Indexer.build``.
|
|
19
|
+
_children: dict[str, frozenset[str]]
|
|
20
|
+
|
|
21
|
+
def __init__(self, snapshot: Snapshot, module_cache=None) -> None:
|
|
22
|
+
self.snapshot = snapshot
|
|
23
|
+
self.index = SourceIndex(
|
|
24
|
+
snapshot=snapshot.info,
|
|
25
|
+
other_files=dict(snapshot.other_files),
|
|
26
|
+
cython={
|
|
27
|
+
path: read_cython(path, content.decode("utf-8", "surrogateescape"))
|
|
28
|
+
for path, content in sorted(snapshot.cython_files.items())
|
|
29
|
+
},
|
|
30
|
+
)
|
|
31
|
+
self.index.errors.extend(snapshot.errors)
|
|
32
|
+
# Optional per-module cache of first-pass facts and second-pass
|
|
33
|
+
# outputs (diffcone.cache.ModuleCache). Applies to every snapshot kind.
|
|
34
|
+
self.module_cache = module_cache
|
|
35
|
+
self.scopes: dict[str, ModuleScope] = {}
|
|
36
|
+
self.class_scopes: dict[str, ClassScope] = {}
|
|
37
|
+
self._module_prefixes: set[str] = set()
|
|
38
|
+
self._bases_final = False
|
|
39
|
+
# Where writes go: the global output is backed by the index; pass 2
|
|
40
|
+
# swaps in a per-module output so it can be cached (see _Output).
|
|
41
|
+
self._global = _Output(
|
|
42
|
+
edges=self.index.edges,
|
|
43
|
+
unresolved=self.index.unresolved,
|
|
44
|
+
external=self.index.external,
|
|
45
|
+
reflection=self.index.reflection,
|
|
46
|
+
class_attributes=self.index.class_attributes,
|
|
47
|
+
class_bases=self.index.class_bases,
|
|
48
|
+
open_classes=self.index.open_classes,
|
|
49
|
+
doc_decorated=self.index.doc_decorated,
|
|
50
|
+
)
|
|
51
|
+
self.out = self._global
|
|
52
|
+
# Symbols and class scopes added by the module being indexed, and
|
|
53
|
+
# whether one of its symbols collided with an earlier module's.
|
|
54
|
+
self._added_symbols: list[Symbol] = []
|
|
55
|
+
self._added_classes: list[ClassScope] = []
|
|
56
|
+
self._collided = False
|
|
57
|
+
# Transitive in-scope descendants per class, built once bases are final.
|
|
58
|
+
self._descendants: dict[str, tuple[str, ...]] = {}
|
|
59
|
+
# Import bindings being resolved (guards self-referential imports).
|
|
60
|
+
self._resolving_bindings: set[tuple[str, str]] = set()
|
|
61
|
+
# Top-level import name -> the analysed package it most likely means
|
|
62
|
+
# under another root (see Resolver._misrooted); built on first use.
|
|
63
|
+
self._misrooted_names: dict[str, str] | None = None
|
|
64
|
+
# Classes with an unresolved ``super().<name>``, per name (final pass).
|
|
65
|
+
self._super_misses: dict[str, set[str]] = defaultdict(set)
|
|
66
|
+
|
|
67
|
+
def _error(self, path: str, message: str) -> None:
|
|
68
|
+
self.index.errors.append(
|
|
69
|
+
AnalysisError(revision=self.snapshot.revision, path=path, message=message)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def _add_symbol(self, symbol: Symbol) -> bool:
|
|
73
|
+
existing = self.index.symbols.get(symbol.id)
|
|
74
|
+
if existing is not None:
|
|
75
|
+
self._error(
|
|
76
|
+
symbol.path,
|
|
77
|
+
f"symbol identity {symbol.id!r} collides with {existing.kind} in {existing.path}",
|
|
78
|
+
)
|
|
79
|
+
self._collided = True
|
|
80
|
+
return False
|
|
81
|
+
self.index.symbols[symbol.id] = symbol
|
|
82
|
+
self._added_symbols.append(symbol)
|
|
83
|
+
return True
|