diffcone 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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