smelt-cli 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.
- smelt/__init__.py +6 -0
- smelt/__main__.py +5 -0
- smelt/analysis/__init__.py +27 -0
- smelt/analysis/context.py +106 -0
- smelt/analysis/files.py +198 -0
- smelt/analysis/graphs.py +77 -0
- smelt/analysis/imports.py +303 -0
- smelt/analysis/parsing.py +110 -0
- smelt/analysis/roles.py +122 -0
- smelt/analysis/syntax.py +246 -0
- smelt/analysis/types.py +181 -0
- smelt/cli/__init__.py +3 -0
- smelt/cli/app.py +140 -0
- smelt/cli/commands/__init__.py +18 -0
- smelt/cli/commands/adopt.py +116 -0
- smelt/cli/commands/check.py +80 -0
- smelt/cli/commands/discover.py +45 -0
- smelt/cli/commands/info.py +107 -0
- smelt/cli/commands/verify.py +60 -0
- smelt/cli/support.py +64 -0
- smelt/config/__init__.py +24 -0
- smelt/config/discovery.py +49 -0
- smelt/config/errors.py +51 -0
- smelt/config/loader.py +182 -0
- smelt/config/models.py +452 -0
- smelt/config/patterns.py +47 -0
- smelt/config/schema.py +23 -0
- smelt/config/validation.py +193 -0
- smelt/diagnostics/__init__.py +0 -0
- smelt/diagnostics/debt.py +119 -0
- smelt/diagnostics/dedupe.py +44 -0
- smelt/diagnostics/render/__init__.py +0 -0
- smelt/diagnostics/render/machine.py +137 -0
- smelt/diagnostics/render/text.py +197 -0
- smelt/diagnostics/report.py +61 -0
- smelt/diagnostics/suppressions.py +80 -0
- smelt/diagnostics/violation.py +109 -0
- smelt/docs.py +97 -0
- smelt/engine/__init__.py +0 -0
- smelt/engine/architecture_map.py +125 -0
- smelt/engine/briefing.py +364 -0
- smelt/engine/changes.py +81 -0
- smelt/engine/check.py +393 -0
- smelt/engine/inference.py +457 -0
- smelt/engine/verify.py +77 -0
- smelt/model/__init__.py +9 -0
- smelt/model/architecture.py +237 -0
- smelt/py.typed +0 -0
- smelt/rules/__init__.py +0 -0
- smelt/rules/base.py +142 -0
- smelt/rules/code/__init__.py +0 -0
- smelt/rules/code/common.py +119 -0
- smelt/rules/code/construction.py +172 -0
- smelt/rules/code/container.py +104 -0
- smelt/rules/code/inheritance.py +99 -0
- smelt/rules/code/roles.py +100 -0
- smelt/rules/code/self_reference.py +104 -0
- smelt/rules/common.py +127 -0
- smelt/rules/dependencies/__init__.py +0 -0
- smelt/rules/dependencies/composition_root.py +123 -0
- smelt/rules/dependencies/cycles.py +207 -0
- smelt/rules/dependencies/features.py +132 -0
- smelt/rules/dependencies/layers.py +220 -0
- smelt/rules/dependencies/third_party.py +107 -0
- smelt/rules/meta.py +72 -0
- smelt/rules/registry.py +135 -0
- smelt/rules/structure/__init__.py +0 -0
- smelt/rules/structure/layout.py +213 -0
- smelt/rules/structure/naming.py +63 -0
- smelt/rules/structure/roles.py +63 -0
- smelt/rules/testing/__init__.py +0 -0
- smelt/rules/testing/api.py +115 -0
- smelt/rules/testing/bloat.py +84 -0
- smelt/rules/testing/common.py +238 -0
- smelt/rules/testing/location.py +253 -0
- smelt/rules/testing/mocks.py +294 -0
- smelt/rules/testing/patching.py +152 -0
- smelt_cli-0.1.0.dist-info/METADATA +199 -0
- smelt_cli-0.1.0.dist-info/RECORD +81 -0
- smelt_cli-0.1.0.dist-info/WHEEL +4 -0
- smelt_cli-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import sys
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from importlib.machinery import PathFinder
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
import grimp
|
|
12
|
+
from grimp.application.config import settings as grimp_settings
|
|
13
|
+
from grimp.application.ports.modulefinder import (
|
|
14
|
+
AbstractModuleFinder,
|
|
15
|
+
FoundPackage,
|
|
16
|
+
ModuleFile,
|
|
17
|
+
)
|
|
18
|
+
from grimp.application.ports.packagefinder import AbstractPackageFinder
|
|
19
|
+
from grimp.domain.valueobjects import Module
|
|
20
|
+
from grimp.exceptions import SourceSyntaxError
|
|
21
|
+
|
|
22
|
+
from smelt.analysis.parsing import (
|
|
23
|
+
AnalysisError,
|
|
24
|
+
AstCache,
|
|
25
|
+
python_note,
|
|
26
|
+
resolve_relative,
|
|
27
|
+
type_checking_lines,
|
|
28
|
+
)
|
|
29
|
+
from smelt.model import is_within
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from collections.abc import Iterator
|
|
33
|
+
|
|
34
|
+
from grimp.application.ports.filesystem import AbstractFileSystem
|
|
35
|
+
|
|
36
|
+
from smelt.analysis.files import FileIndex
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class ImportDetail:
|
|
41
|
+
importer: str
|
|
42
|
+
# Grimp's resolved target; external packages are squashed to their top level.
|
|
43
|
+
imported: str
|
|
44
|
+
line: int
|
|
45
|
+
line_contents: str
|
|
46
|
+
# Fully qualified names the statement imports, e.g. ``sqlalchemy.orm.Session``.
|
|
47
|
+
names: tuple[str, ...]
|
|
48
|
+
type_checking: bool
|
|
49
|
+
external: bool
|
|
50
|
+
column: int
|
|
51
|
+
end_column: int
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def top_level(self) -> str:
|
|
55
|
+
return self.imported.split(".")[0]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_stdlib(module: str) -> bool:
|
|
59
|
+
return module.split(".", maxsplit=1)[0] in sys.stdlib_module_names
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _SourceRootPackageFinder(AbstractPackageFinder):
|
|
63
|
+
def __init__(self, search_paths: list[str]) -> None:
|
|
64
|
+
self._search_paths = search_paths
|
|
65
|
+
|
|
66
|
+
def determine_package_directories(
|
|
67
|
+
self, package_name: str, file_system: AbstractFileSystem
|
|
68
|
+
) -> set[str]:
|
|
69
|
+
spec = PathFinder.find_spec(package_name, self._search_paths)
|
|
70
|
+
if spec is None or not spec.submodule_search_locations:
|
|
71
|
+
msg = f"Could not find package '{package_name}'."
|
|
72
|
+
raise ValueError(msg)
|
|
73
|
+
return set(spec.submodule_search_locations)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _IndexedModuleFinder(AbstractModuleFinder):
|
|
77
|
+
"""Give Grimp the same modules and namespace packages as Smelt's file index."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, files: FileIndex) -> None:
|
|
80
|
+
self._files = files
|
|
81
|
+
|
|
82
|
+
def find_package(
|
|
83
|
+
self,
|
|
84
|
+
package_name: str,
|
|
85
|
+
package_directory: str,
|
|
86
|
+
file_system: AbstractFileSystem,
|
|
87
|
+
) -> FoundPackage:
|
|
88
|
+
directory = Path(package_directory)
|
|
89
|
+
module_files = frozenset(
|
|
90
|
+
ModuleFile(
|
|
91
|
+
Module(source.module), file_system.get_mtime(str(source.absolute))
|
|
92
|
+
)
|
|
93
|
+
for source in self._files.sources.values()
|
|
94
|
+
if is_within(source.module, package_name)
|
|
95
|
+
and source.absolute.is_relative_to(directory)
|
|
96
|
+
)
|
|
97
|
+
namespace_packages = frozenset(
|
|
98
|
+
package.module
|
|
99
|
+
for package in self._files.packages.values()
|
|
100
|
+
if is_within(package.module, package_name)
|
|
101
|
+
and not package.has_init
|
|
102
|
+
and (self._files.root / package.path).is_relative_to(directory)
|
|
103
|
+
)
|
|
104
|
+
return FoundPackage(
|
|
105
|
+
name=package_name,
|
|
106
|
+
directory=package_directory,
|
|
107
|
+
module_files=module_files,
|
|
108
|
+
namespace_packages=namespace_packages,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ImportIndex:
|
|
113
|
+
def __init__(
|
|
114
|
+
self, graph: grimp.ImportGraph, files: FileIndex, asts: AstCache
|
|
115
|
+
) -> None:
|
|
116
|
+
self.graph = graph
|
|
117
|
+
self.files = files
|
|
118
|
+
self._asts = asts
|
|
119
|
+
roots = files.config.project.root_packages
|
|
120
|
+
self.first_party = frozenset(
|
|
121
|
+
m for m in graph.modules if any(is_within(m, r) for r in roots)
|
|
122
|
+
)
|
|
123
|
+
self._outgoing: dict[str, list[ImportDetail]] = {}
|
|
124
|
+
self._incoming: dict[str, list[ImportDetail]] | None = None
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def build(cls, files: FileIndex, asts: AstCache) -> ImportIndex:
|
|
128
|
+
project = files.config.project
|
|
129
|
+
packages = [
|
|
130
|
+
p for p in project.root_packages if p not in files.missing_root_packages
|
|
131
|
+
]
|
|
132
|
+
if not packages:
|
|
133
|
+
msg = "none of project.root_packages were found under project.source_roots"
|
|
134
|
+
raise AnalysisError(msg)
|
|
135
|
+
search_paths = [
|
|
136
|
+
str((files.root / root).resolve()) for root in project.source_roots
|
|
137
|
+
]
|
|
138
|
+
previous_package_finder = grimp_settings.PACKAGE_FINDER
|
|
139
|
+
previous_module_finder = grimp_settings.MODULE_FINDER
|
|
140
|
+
grimp_settings.configure(
|
|
141
|
+
PACKAGE_FINDER=_SourceRootPackageFinder(search_paths),
|
|
142
|
+
MODULE_FINDER=_IndexedModuleFinder(files),
|
|
143
|
+
)
|
|
144
|
+
try:
|
|
145
|
+
graph = grimp.build_graph(
|
|
146
|
+
packages[0],
|
|
147
|
+
*packages[1:],
|
|
148
|
+
include_external_packages=True,
|
|
149
|
+
cache_dir=None,
|
|
150
|
+
)
|
|
151
|
+
except SourceSyntaxError as exc:
|
|
152
|
+
raise AnalysisError(str(exc) + python_note(files.root)) from exc
|
|
153
|
+
finally:
|
|
154
|
+
grimp_settings.configure(
|
|
155
|
+
PACKAGE_FINDER=previous_package_finder,
|
|
156
|
+
MODULE_FINDER=previous_module_finder,
|
|
157
|
+
)
|
|
158
|
+
for module in list(graph.modules):
|
|
159
|
+
if (
|
|
160
|
+
any(is_within(module, p) for p in packages)
|
|
161
|
+
and module not in files.sources
|
|
162
|
+
and module not in files.packages
|
|
163
|
+
):
|
|
164
|
+
graph.remove_module(module)
|
|
165
|
+
missing = files.sources.keys() - graph.modules
|
|
166
|
+
if missing:
|
|
167
|
+
examples = ", ".join(sorted(missing)[:3])
|
|
168
|
+
raise AnalysisError(
|
|
169
|
+
f"import graph omitted {len(missing)} source module(s): {examples}"
|
|
170
|
+
)
|
|
171
|
+
return cls(graph, files, asts)
|
|
172
|
+
|
|
173
|
+
def is_external(self, module: str) -> bool:
|
|
174
|
+
return module not in self.first_party
|
|
175
|
+
|
|
176
|
+
def modules(self) -> list[str]:
|
|
177
|
+
return sorted(m for m in self.first_party if m in self.files.sources)
|
|
178
|
+
|
|
179
|
+
def imports_of(self, module: str) -> list[ImportDetail]:
|
|
180
|
+
cached = self._outgoing.get(module)
|
|
181
|
+
if cached is None:
|
|
182
|
+
cached = self._details_for(module)
|
|
183
|
+
self._outgoing[module] = cached
|
|
184
|
+
return cached
|
|
185
|
+
|
|
186
|
+
def importers_of(self, module: str) -> list[ImportDetail]:
|
|
187
|
+
if self._incoming is None:
|
|
188
|
+
incoming: dict[str, list[ImportDetail]] = defaultdict(list)
|
|
189
|
+
for detail in self.all_imports():
|
|
190
|
+
incoming[detail.imported].append(detail)
|
|
191
|
+
self._incoming = dict(incoming)
|
|
192
|
+
return self._incoming.get(module, [])
|
|
193
|
+
|
|
194
|
+
def all_imports(self) -> Iterator[ImportDetail]:
|
|
195
|
+
for module in self.modules():
|
|
196
|
+
yield from self.imports_of(module)
|
|
197
|
+
|
|
198
|
+
def first_party_edges(
|
|
199
|
+
self, *, include_type_checking: bool = True
|
|
200
|
+
) -> dict[str, set[str]]:
|
|
201
|
+
adjacency: dict[str, set[str]] = defaultdict(set)
|
|
202
|
+
for detail in self.all_imports():
|
|
203
|
+
if detail.external or (detail.type_checking and not include_type_checking):
|
|
204
|
+
continue
|
|
205
|
+
adjacency[detail.importer].add(detail.imported)
|
|
206
|
+
return adjacency
|
|
207
|
+
|
|
208
|
+
def _details_for(self, module: str) -> list[ImportDetail]:
|
|
209
|
+
source = self.files.sources.get(module)
|
|
210
|
+
if source is None or module not in self.first_party:
|
|
211
|
+
return []
|
|
212
|
+
tree = self._asts.parse(source.path)
|
|
213
|
+
statements = _statements_by_line(tree)
|
|
214
|
+
guarded = type_checking_lines(tree)
|
|
215
|
+
details: list[ImportDetail] = []
|
|
216
|
+
for imported in sorted(self.graph.find_modules_directly_imported_by(module)):
|
|
217
|
+
external = imported not in self.first_party
|
|
218
|
+
for raw in self.graph.get_import_details(
|
|
219
|
+
importer=module, imported=imported
|
|
220
|
+
):
|
|
221
|
+
line = int(raw["line_number"])
|
|
222
|
+
node = statements.get(line)
|
|
223
|
+
names, column, end_column = _describe(
|
|
224
|
+
node, module, source.is_package, imported, raw["line_contents"]
|
|
225
|
+
)
|
|
226
|
+
details.append(
|
|
227
|
+
ImportDetail(
|
|
228
|
+
importer=module,
|
|
229
|
+
imported=imported,
|
|
230
|
+
line=line,
|
|
231
|
+
line_contents=str(raw["line_contents"]),
|
|
232
|
+
names=names,
|
|
233
|
+
type_checking=line in guarded,
|
|
234
|
+
external=external,
|
|
235
|
+
column=column,
|
|
236
|
+
end_column=end_column,
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
details.sort(key=lambda d: (d.line, d.column, d.imported))
|
|
240
|
+
return details
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _statements_by_line(tree: ast.Module) -> dict[int, ast.Import | ast.ImportFrom]:
|
|
244
|
+
return {
|
|
245
|
+
node.lineno: node
|
|
246
|
+
for node in ast.walk(tree)
|
|
247
|
+
if isinstance(node, ast.Import | ast.ImportFrom)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _describe(
|
|
252
|
+
node: ast.Import | ast.ImportFrom | None,
|
|
253
|
+
module: str,
|
|
254
|
+
is_package: bool,
|
|
255
|
+
imported: str,
|
|
256
|
+
line_contents: str,
|
|
257
|
+
) -> tuple[tuple[str, ...], int, int]:
|
|
258
|
+
"""Imported names and the 1-based column span to underline on the first line."""
|
|
259
|
+
if node is None:
|
|
260
|
+
return (imported,), 1, len(line_contents) + 1
|
|
261
|
+
if isinstance(node, ast.Import):
|
|
262
|
+
return _describe_import(node, imported, line_contents)
|
|
263
|
+
return _describe_import_from(node, module, is_package, imported)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _describe_import(
|
|
267
|
+
node: ast.Import, imported: str, line_contents: str
|
|
268
|
+
) -> tuple[tuple[str, ...], int, int]:
|
|
269
|
+
first_line_end = (
|
|
270
|
+
node.end_col_offset if node.end_lineno == node.lineno else len(line_contents)
|
|
271
|
+
)
|
|
272
|
+
names: tuple[str, ...] = (imported,)
|
|
273
|
+
span = (node.col_offset + 1, (first_line_end or 0) + 1)
|
|
274
|
+
for alias in node.names:
|
|
275
|
+
if is_within(alias.name, imported) or is_within(imported, alias.name):
|
|
276
|
+
names = (alias.name,)
|
|
277
|
+
if alias.lineno == node.lineno:
|
|
278
|
+
span = (alias.col_offset + 1, (alias.end_col_offset or 0) + 1)
|
|
279
|
+
break
|
|
280
|
+
return names, *span
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _describe_import_from(
|
|
284
|
+
node: ast.ImportFrom, module: str, is_package: bool, imported: str
|
|
285
|
+
) -> tuple[tuple[str, ...], int, int]:
|
|
286
|
+
base = resolve_relative(
|
|
287
|
+
module, is_package=is_package, level=node.level, target=node.module
|
|
288
|
+
)
|
|
289
|
+
names = tuple(
|
|
290
|
+
f"{base}.{alias.name}" if base else alias.name for alias in node.names
|
|
291
|
+
)
|
|
292
|
+
for alias, name in zip(node.names, names, strict=True):
|
|
293
|
+
if name == imported and alias.lineno == node.lineno:
|
|
294
|
+
return (name,), alias.col_offset + 1, (alias.end_col_offset or 0) + 1
|
|
295
|
+
matching = tuple(
|
|
296
|
+
n for n in names if is_within(n, imported) or is_within(imported, n)
|
|
297
|
+
)
|
|
298
|
+
if len(node.names) == 1 and node.names[0].lineno == node.lineno:
|
|
299
|
+
alias = node.names[0]
|
|
300
|
+
return matching or names, alias.col_offset + 1, (alias.end_col_offset or 0) + 1
|
|
301
|
+
start = node.col_offset + len("from ")
|
|
302
|
+
text = "." * node.level + (node.module or "")
|
|
303
|
+
return matching or names, start + 1, start + len(text) + 1
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from smelt.analysis.files import FileIndex
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AnalysisError(Exception):
|
|
17
|
+
"""The project cannot be analyzed (missing packages, syntax errors, ...)."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class AstCache:
|
|
22
|
+
files: FileIndex
|
|
23
|
+
_trees: dict[str, ast.Module] = field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
def parse(self, path: str) -> ast.Module:
|
|
26
|
+
tree = self._trees.get(path)
|
|
27
|
+
if tree is None:
|
|
28
|
+
source = self.files.read_text(path)
|
|
29
|
+
try:
|
|
30
|
+
tree = ast.parse(source, filename=path)
|
|
31
|
+
except SyntaxError as exc:
|
|
32
|
+
msg = f"{path}:{exc.lineno}: syntax error: {exc.msg}"
|
|
33
|
+
raise AnalysisError(msg + python_note(self.files.root)) from exc
|
|
34
|
+
self._trees[path] = tree
|
|
35
|
+
return tree
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_VERSION_BOUND = re.compile(r"(?:>=|~=|==)\s*3\.(\d+)")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def python_note(root: Path) -> str:
|
|
42
|
+
"""Explain a syntax error that may just be newer syntax than the running Python.
|
|
43
|
+
|
|
44
|
+
``ast`` only knows the grammar of the interpreter smelt runs on, so a 3.14
|
|
45
|
+
project checked by smelt on 3.12 fails on ``except A, B:`` or t-strings.
|
|
46
|
+
"""
|
|
47
|
+
running = sys.version_info[:2]
|
|
48
|
+
note = f" (parsed by Python {running[0]}.{running[1]})"
|
|
49
|
+
target = _target_python(root)
|
|
50
|
+
if target is not None and target > running:
|
|
51
|
+
version = f"{target[0]}.{target[1]}"
|
|
52
|
+
note += (
|
|
53
|
+
f"; the project targets Python {version}, so run smelt on it, "
|
|
54
|
+
f"e.g. `uvx -p {version} --from smelt-cli smelt check`"
|
|
55
|
+
)
|
|
56
|
+
return note
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _target_python(root: Path) -> tuple[int, int] | None:
|
|
60
|
+
found: list[tuple[int, int]] = []
|
|
61
|
+
try:
|
|
62
|
+
data = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))
|
|
63
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
64
|
+
data = {}
|
|
65
|
+
requires = data.get("project", {}).get("requires-python", "")
|
|
66
|
+
if isinstance(requires, str) and (match := _VERSION_BOUND.search(requires)):
|
|
67
|
+
found.append((3, int(match.group(1))))
|
|
68
|
+
try:
|
|
69
|
+
pinned = (root / ".python-version").read_text(encoding="utf-8").split()
|
|
70
|
+
except OSError:
|
|
71
|
+
pinned = []
|
|
72
|
+
if pinned and (match := re.match(r"3\.(\d+)", pinned[0])):
|
|
73
|
+
found.append((3, int(match.group(1))))
|
|
74
|
+
return max(found, default=None)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def type_checking_lines(tree: ast.Module) -> frozenset[int]:
|
|
78
|
+
"""Line numbers inside ``if TYPE_CHECKING:`` blocks."""
|
|
79
|
+
lines: set[int] = set()
|
|
80
|
+
for node in ast.walk(tree):
|
|
81
|
+
if isinstance(node, ast.If) and _is_type_checking_test(node.test):
|
|
82
|
+
for child in node.body:
|
|
83
|
+
end = child.end_lineno or child.lineno
|
|
84
|
+
lines.update(range(child.lineno, end + 1))
|
|
85
|
+
return frozenset(lines)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _is_type_checking_test(test: ast.expr) -> bool:
|
|
89
|
+
if isinstance(test, ast.Name):
|
|
90
|
+
return test.id == "TYPE_CHECKING"
|
|
91
|
+
if isinstance(test, ast.Attribute):
|
|
92
|
+
return test.attr == "TYPE_CHECKING"
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def resolve_relative(
|
|
97
|
+
module: str, *, is_package: bool, level: int, target: str | None
|
|
98
|
+
) -> str:
|
|
99
|
+
"""Resolve ``from <level dots><target> import ...`` inside ``module``."""
|
|
100
|
+
if level == 0:
|
|
101
|
+
return target or ""
|
|
102
|
+
parts = module.split(".")
|
|
103
|
+
if not is_package:
|
|
104
|
+
parts = parts[:-1]
|
|
105
|
+
if level > 1:
|
|
106
|
+
parts = parts[: len(parts) - (level - 1)]
|
|
107
|
+
base = ".".join(parts)
|
|
108
|
+
if target:
|
|
109
|
+
return f"{base}.{target}" if base else target
|
|
110
|
+
return base
|
smelt/analysis/roles.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from smelt.analysis.syntax import ClassInfo, SyntaxIndex
|
|
9
|
+
from smelt.analysis.types import TypeIndex
|
|
10
|
+
from smelt.config.models import SmeltConfig
|
|
11
|
+
|
|
12
|
+
# Subclassing a Protocol without listing Protocol again makes a concrete class.
|
|
13
|
+
_DIRECT_ONLY_BASES = frozenset({"typing.Protocol", "typing_extensions.Protocol"})
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class RoleMatch:
|
|
18
|
+
role: str
|
|
19
|
+
cls: ClassInfo
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RoleIndex:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
config: SmeltConfig,
|
|
26
|
+
syntax: SyntaxIndex,
|
|
27
|
+
types: TypeIndex | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
self.config = config
|
|
30
|
+
self.syntax = syntax
|
|
31
|
+
self.types = types
|
|
32
|
+
self.by_class: dict[str, frozenset[str]] = self._detect()
|
|
33
|
+
|
|
34
|
+
def _detect(self) -> dict[str, frozenset[str]]:
|
|
35
|
+
roles: dict[str, set[str]] = defaultdict(set)
|
|
36
|
+
ancestors = {
|
|
37
|
+
name: self.syntax.ancestors(info)
|
|
38
|
+
for name, info in self.syntax.classes.items()
|
|
39
|
+
}
|
|
40
|
+
self._detect_by_base(roles, ancestors)
|
|
41
|
+
self._detect_by_ancestry(roles, ancestors)
|
|
42
|
+
if self.types is not None:
|
|
43
|
+
self._detect_structurally(roles)
|
|
44
|
+
return {name: frozenset(found) for name, found in roles.items() if found}
|
|
45
|
+
|
|
46
|
+
def _detect_by_base(
|
|
47
|
+
self, roles: dict[str, set[str]], ancestors: dict[str, list[str]]
|
|
48
|
+
) -> None:
|
|
49
|
+
for role_name, role in self.config.architecture.roles.items():
|
|
50
|
+
base = role.detect.base
|
|
51
|
+
if base is None:
|
|
52
|
+
continue
|
|
53
|
+
for name, info in self.syntax.classes.items():
|
|
54
|
+
direct = {self.syntax.canonical(b) for b in info.bases if b}
|
|
55
|
+
wanted = direct if base in _DIRECT_ONLY_BASES else ancestors[name]
|
|
56
|
+
if base in wanted:
|
|
57
|
+
roles[name].add(role_name)
|
|
58
|
+
|
|
59
|
+
def _detect_by_ancestry(
|
|
60
|
+
self, roles: dict[str, set[str]], ancestors: dict[str, list[str]]
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Propagate ``implements`` roles down the inheritance chains, until stable."""
|
|
63
|
+
changed = True
|
|
64
|
+
while changed:
|
|
65
|
+
changed = False
|
|
66
|
+
for role_name, target in self._implement_roles():
|
|
67
|
+
for name in self.syntax.classes:
|
|
68
|
+
if {role_name, target} & roles[name]:
|
|
69
|
+
continue
|
|
70
|
+
if any(target in roles.get(a, ()) for a in ancestors[name]):
|
|
71
|
+
roles[name].add(role_name)
|
|
72
|
+
changed = True
|
|
73
|
+
|
|
74
|
+
def _implement_roles(self) -> list[tuple[str, str]]:
|
|
75
|
+
return [
|
|
76
|
+
(name, role.detect.implements)
|
|
77
|
+
for name, role in self.config.architecture.roles.items()
|
|
78
|
+
if role.detect.implements is not None
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
def _detect_structurally(self, roles: dict[str, set[str]]) -> None:
|
|
82
|
+
"""With type information, a class can implement a port without inheriting it."""
|
|
83
|
+
types = self.types
|
|
84
|
+
if types is None: # pragma: no cover - guarded by the caller
|
|
85
|
+
return
|
|
86
|
+
for role_name, target in self._implement_roles():
|
|
87
|
+
ports = sorted(name for name, found in roles.items() if target in found)
|
|
88
|
+
candidates = [
|
|
89
|
+
name
|
|
90
|
+
for name in self.syntax.classes
|
|
91
|
+
if not {role_name, target} & roles[name]
|
|
92
|
+
]
|
|
93
|
+
pairs = [(name, port) for name in candidates for port in ports]
|
|
94
|
+
types.prepare(pairs)
|
|
95
|
+
for name, port in pairs:
|
|
96
|
+
if types.implements(name, port):
|
|
97
|
+
roles[name].add(role_name)
|
|
98
|
+
|
|
99
|
+
def roles_of(self, qualname: str) -> frozenset[str]:
|
|
100
|
+
return self.by_class.get(qualname, frozenset())
|
|
101
|
+
|
|
102
|
+
def classes_with(self, role: str) -> list[ClassInfo]:
|
|
103
|
+
classes = self.syntax.classes
|
|
104
|
+
return [
|
|
105
|
+
classes[name]
|
|
106
|
+
for name, roles in sorted(self.by_class.items())
|
|
107
|
+
if role in roles
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
def matches(self) -> list[RoleMatch]:
|
|
111
|
+
classes = self.syntax.classes
|
|
112
|
+
return [
|
|
113
|
+
RoleMatch(role, classes[name])
|
|
114
|
+
for name, roles in sorted(self.by_class.items())
|
|
115
|
+
for role in sorted(roles)
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
def module_roles(self) -> dict[str, frozenset[str]]:
|
|
119
|
+
result: dict[str, set[str]] = defaultdict(set)
|
|
120
|
+
for match in self.matches():
|
|
121
|
+
result[match.cls.module].add(match.role)
|
|
122
|
+
return {module: frozenset(roles) for module, roles in result.items()}
|