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.
Files changed (81) hide show
  1. smelt/__init__.py +6 -0
  2. smelt/__main__.py +5 -0
  3. smelt/analysis/__init__.py +27 -0
  4. smelt/analysis/context.py +106 -0
  5. smelt/analysis/files.py +198 -0
  6. smelt/analysis/graphs.py +77 -0
  7. smelt/analysis/imports.py +303 -0
  8. smelt/analysis/parsing.py +110 -0
  9. smelt/analysis/roles.py +122 -0
  10. smelt/analysis/syntax.py +246 -0
  11. smelt/analysis/types.py +181 -0
  12. smelt/cli/__init__.py +3 -0
  13. smelt/cli/app.py +140 -0
  14. smelt/cli/commands/__init__.py +18 -0
  15. smelt/cli/commands/adopt.py +116 -0
  16. smelt/cli/commands/check.py +80 -0
  17. smelt/cli/commands/discover.py +45 -0
  18. smelt/cli/commands/info.py +107 -0
  19. smelt/cli/commands/verify.py +60 -0
  20. smelt/cli/support.py +64 -0
  21. smelt/config/__init__.py +24 -0
  22. smelt/config/discovery.py +49 -0
  23. smelt/config/errors.py +51 -0
  24. smelt/config/loader.py +182 -0
  25. smelt/config/models.py +452 -0
  26. smelt/config/patterns.py +47 -0
  27. smelt/config/schema.py +23 -0
  28. smelt/config/validation.py +193 -0
  29. smelt/diagnostics/__init__.py +0 -0
  30. smelt/diagnostics/debt.py +119 -0
  31. smelt/diagnostics/dedupe.py +44 -0
  32. smelt/diagnostics/render/__init__.py +0 -0
  33. smelt/diagnostics/render/machine.py +137 -0
  34. smelt/diagnostics/render/text.py +197 -0
  35. smelt/diagnostics/report.py +61 -0
  36. smelt/diagnostics/suppressions.py +80 -0
  37. smelt/diagnostics/violation.py +109 -0
  38. smelt/docs.py +97 -0
  39. smelt/engine/__init__.py +0 -0
  40. smelt/engine/architecture_map.py +125 -0
  41. smelt/engine/briefing.py +364 -0
  42. smelt/engine/changes.py +81 -0
  43. smelt/engine/check.py +393 -0
  44. smelt/engine/inference.py +457 -0
  45. smelt/engine/verify.py +77 -0
  46. smelt/model/__init__.py +9 -0
  47. smelt/model/architecture.py +237 -0
  48. smelt/py.typed +0 -0
  49. smelt/rules/__init__.py +0 -0
  50. smelt/rules/base.py +142 -0
  51. smelt/rules/code/__init__.py +0 -0
  52. smelt/rules/code/common.py +119 -0
  53. smelt/rules/code/construction.py +172 -0
  54. smelt/rules/code/container.py +104 -0
  55. smelt/rules/code/inheritance.py +99 -0
  56. smelt/rules/code/roles.py +100 -0
  57. smelt/rules/code/self_reference.py +104 -0
  58. smelt/rules/common.py +127 -0
  59. smelt/rules/dependencies/__init__.py +0 -0
  60. smelt/rules/dependencies/composition_root.py +123 -0
  61. smelt/rules/dependencies/cycles.py +207 -0
  62. smelt/rules/dependencies/features.py +132 -0
  63. smelt/rules/dependencies/layers.py +220 -0
  64. smelt/rules/dependencies/third_party.py +107 -0
  65. smelt/rules/meta.py +72 -0
  66. smelt/rules/registry.py +135 -0
  67. smelt/rules/structure/__init__.py +0 -0
  68. smelt/rules/structure/layout.py +213 -0
  69. smelt/rules/structure/naming.py +63 -0
  70. smelt/rules/structure/roles.py +63 -0
  71. smelt/rules/testing/__init__.py +0 -0
  72. smelt/rules/testing/api.py +115 -0
  73. smelt/rules/testing/bloat.py +84 -0
  74. smelt/rules/testing/common.py +238 -0
  75. smelt/rules/testing/location.py +253 -0
  76. smelt/rules/testing/mocks.py +294 -0
  77. smelt/rules/testing/patching.py +152 -0
  78. smelt_cli-0.1.0.dist-info/METADATA +199 -0
  79. smelt_cli-0.1.0.dist-info/RECORD +81 -0
  80. smelt_cli-0.1.0.dist-info/WHEEL +4 -0
  81. smelt_cli-0.1.0.dist-info/entry_points.txt +3 -0
smelt/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("smelt-cli")
5
+ except PackageNotFoundError: # pragma: no cover
6
+ __version__ = "0.0.0"
smelt/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from smelt.cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,27 @@
1
+ from smelt.analysis.context import AnalysisContext, ChangeSet, FileChange, Index
2
+ from smelt.analysis.files import FileIndex, SourceFile, TestFile
3
+ from smelt.analysis.imports import ImportDetail, ImportIndex, is_stdlib
4
+ from smelt.analysis.parsing import AnalysisError
5
+ from smelt.analysis.roles import RoleIndex
6
+ from smelt.analysis.syntax import ClassInfo, ModuleSyntax, SyntaxIndex
7
+ from smelt.analysis.types import PyrightTypes, TypeIndex
8
+
9
+ __all__ = [
10
+ "AnalysisContext",
11
+ "AnalysisError",
12
+ "ChangeSet",
13
+ "ClassInfo",
14
+ "FileChange",
15
+ "FileIndex",
16
+ "ImportDetail",
17
+ "ImportIndex",
18
+ "Index",
19
+ "ModuleSyntax",
20
+ "PyrightTypes",
21
+ "RoleIndex",
22
+ "SourceFile",
23
+ "SyntaxIndex",
24
+ "TestFile",
25
+ "TypeIndex",
26
+ "is_stdlib",
27
+ ]
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import StrEnum
5
+ from functools import cached_property
6
+ from typing import TYPE_CHECKING
7
+
8
+ from smelt.analysis.files import FileIndex
9
+ from smelt.analysis.imports import ImportIndex
10
+ from smelt.analysis.parsing import AstCache
11
+ from smelt.analysis.roles import RoleIndex
12
+ from smelt.analysis.syntax import SyntaxIndex
13
+ from smelt.analysis.types import PyrightTypes, TypeIndex
14
+ from smelt.model import ArchitectureModel
15
+
16
+ if TYPE_CHECKING:
17
+ from pathlib import Path
18
+
19
+ from smelt.config.models import SmeltConfig
20
+
21
+
22
+ class Index(StrEnum):
23
+ FILES = "files"
24
+ IMPORTS = "imports"
25
+ SYNTAX = "syntax"
26
+ ROLES = "roles"
27
+ TYPES = "types"
28
+ CHANGES = "changes"
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class FileChange:
33
+ path: str
34
+ added: int
35
+ deleted: int
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class ChangeSet:
40
+ """Files changed against a git base; paths are relative to the project root."""
41
+
42
+ files: dict[str, FileChange] = field(default_factory=dict)
43
+
44
+ def __contains__(self, path: object) -> bool:
45
+ return path in self.files
46
+
47
+ @property
48
+ def paths(self) -> frozenset[str]:
49
+ return frozenset(self.files)
50
+
51
+
52
+ class AnalysisContext:
53
+ def __init__(
54
+ self,
55
+ root: Path,
56
+ config: SmeltConfig,
57
+ *,
58
+ changes: ChangeSet | None = None,
59
+ types: TypeIndex | None = None,
60
+ ) -> None:
61
+ self.root = root
62
+ self.config = config
63
+ self.changes = changes
64
+ self._types = types
65
+
66
+ @cached_property
67
+ def types(self) -> TypeIndex | None:
68
+ """The configured type backend, or None when there is none to be had."""
69
+ if self._types is not None:
70
+ return self._types
71
+ if self.config.analysis.types == "pyright":
72
+ return PyrightTypes.discover(self.root, self.config)
73
+ return None
74
+
75
+ @cached_property
76
+ def files(self) -> FileIndex:
77
+ return FileIndex.discover(self.root, self.config)
78
+
79
+ @cached_property
80
+ def asts(self) -> AstCache:
81
+ return AstCache(self.files)
82
+
83
+ @cached_property
84
+ def model(self) -> ArchitectureModel:
85
+ return ArchitectureModel.build(
86
+ self.config, self.files.sources, self.files.packages
87
+ )
88
+
89
+ @cached_property
90
+ def imports(self) -> ImportIndex:
91
+ return ImportIndex.build(self.files, self.asts)
92
+
93
+ @cached_property
94
+ def syntax(self) -> SyntaxIndex:
95
+ return SyntaxIndex(self.files, self.asts)
96
+
97
+ @cached_property
98
+ def roles(self) -> RoleIndex:
99
+ return RoleIndex(self.config, self.syntax, self.types)
100
+
101
+ def ensure(self, indexes: frozenset[Index]) -> None:
102
+ """Build the requested indexes up front so failures surface before rules run."""
103
+ for index in sorted(indexes):
104
+ if index is Index.TYPES or index is Index.CHANGES:
105
+ continue
106
+ getattr(self, index.value)
@@ -0,0 +1,198 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from dataclasses import dataclass, field
5
+ from functools import cached_property
6
+ from typing import TYPE_CHECKING
7
+
8
+ from smelt.config.patterns import path_matches
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Iterable, Iterator
12
+ from pathlib import Path
13
+
14
+ from smelt.config.models import SmeltConfig
15
+
16
+ _SKIP_DIRS = frozenset({"__pycache__", ".git", ".venv", "venv", "node_modules"})
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class SourceFile:
21
+ module: str
22
+ path: str # POSIX, relative to the project root
23
+ absolute: Path
24
+ is_package: bool # an ``__init__.py``
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class TestFile:
29
+ path: str
30
+ absolute: Path
31
+ test_root: str
32
+
33
+ @property
34
+ def name(self) -> str:
35
+ return self.path.rsplit("/", 1)[-1]
36
+
37
+ @property
38
+ def is_conftest(self) -> bool:
39
+ return self.name == "conftest.py"
40
+
41
+
42
+ @dataclass(slots=True)
43
+ class PackageDir:
44
+ module: str
45
+ path: str
46
+ source_root: str
47
+ has_init: bool
48
+
49
+
50
+ @dataclass
51
+ class FileIndex:
52
+ root: Path
53
+ config: SmeltConfig
54
+ sources: dict[str, SourceFile] = field(default_factory=dict)
55
+ packages: dict[str, PackageDir] = field(default_factory=dict)
56
+ tests: dict[str, TestFile] = field(default_factory=dict)
57
+ missing_root_packages: list[str] = field(default_factory=list)
58
+ _text_cache: dict[str, str] = field(default_factory=dict, repr=False)
59
+
60
+ @classmethod
61
+ def discover(cls, root: Path, config: SmeltConfig) -> FileIndex:
62
+ index = cls(root=root, config=config)
63
+ index._discover_sources()
64
+ index._discover_tests()
65
+ return index
66
+
67
+ def is_excluded(self, path: str) -> bool:
68
+ return any(
69
+ path_matches(pattern, path) for pattern in self.config.project.exclude
70
+ )
71
+
72
+ def relative(self, path: Path) -> str:
73
+ try:
74
+ return path.resolve().relative_to(self.root.resolve()).as_posix()
75
+ except ValueError:
76
+ return path.as_posix()
77
+
78
+ def _discover_sources(self) -> None:
79
+ project = self.config.project
80
+ for package in project.root_packages:
81
+ found = False
82
+ for source_root in project.source_roots:
83
+ directory = self.root / source_root / package
84
+ if directory.is_dir():
85
+ found = True
86
+ self._walk_package(directory, package, source_root)
87
+ break
88
+ if not found:
89
+ self.missing_root_packages.append(package)
90
+
91
+ def _walk_package(self, directory: Path, module: str, source_root: str) -> None:
92
+ rel_dir = self.relative(directory)
93
+ if self.is_excluded(rel_dir):
94
+ return
95
+ init = directory / "__init__.py"
96
+ self.packages[module] = PackageDir(module, rel_dir, source_root, init.is_file())
97
+ for entry in sorted(directory.iterdir(), key=lambda p: p.name):
98
+ if entry.is_dir():
99
+ if entry.name in _SKIP_DIRS or not entry.name.isidentifier():
100
+ continue
101
+ if _contains_python(entry):
102
+ self._walk_package(entry, f"{module}.{entry.name}", source_root)
103
+ elif entry.suffix == ".py" and entry.is_file():
104
+ rel = self.relative(entry)
105
+ if self.is_excluded(rel):
106
+ continue
107
+ if entry.name == "__init__.py":
108
+ self.sources[module] = SourceFile(
109
+ module, rel, entry, is_package=True
110
+ )
111
+ elif entry.stem.isidentifier():
112
+ name = f"{module}.{entry.stem}"
113
+ self.sources[name] = SourceFile(name, rel, entry, is_package=False)
114
+
115
+ def _discover_tests(self) -> None:
116
+ source_paths = {source.path for source in self.sources.values()}
117
+ for test_root in self.config.project.test_roots:
118
+ directory = self.root / test_root
119
+ if not directory.is_dir():
120
+ continue
121
+ for path in sorted(_python_files(directory)):
122
+ rel = self.relative(path)
123
+ if rel in source_paths or self.is_excluded(rel):
124
+ continue
125
+ name = path.name
126
+ if (
127
+ name == "conftest.py"
128
+ or name.startswith("test_")
129
+ or path.stem.endswith("_test")
130
+ ):
131
+ self.tests[rel] = TestFile(rel, path, test_root.strip("/"))
132
+
133
+ @cached_property
134
+ def by_path(self) -> dict[str, SourceFile]:
135
+ return {source.path: source for source in self.sources.values()}
136
+
137
+ def source_for_path(self, path: str) -> SourceFile | None:
138
+ return self.by_path.get(path)
139
+
140
+ def path_for_module(self, module: str) -> str | None:
141
+ source = self.sources.get(module)
142
+ if source is not None:
143
+ return source.path
144
+ package = self.packages.get(module)
145
+ return package.path if package else None
146
+
147
+ def read_text(self, path: str) -> str:
148
+ cached = self._text_cache.get(path)
149
+ if cached is None:
150
+ cached = (self.root / path).read_text(encoding="utf-8", errors="replace")
151
+ self._text_cache[path] = cached
152
+ return cached
153
+
154
+ def lines(self, path: str) -> list[str]:
155
+ return self.read_text(path).splitlines()
156
+
157
+ def line(self, path: str, number: int) -> str:
158
+ lines = self.lines(path)
159
+ return lines[number - 1] if 0 < number <= len(lines) else ""
160
+
161
+ def sha256(self, path: str) -> str:
162
+ return hashlib.sha256(self.read_text(path).encode()).hexdigest()
163
+
164
+ def size(self, path: str) -> int:
165
+ return len(self.read_text(path).encode())
166
+
167
+ def all_python_paths(self) -> Iterator[str]:
168
+ yield from (source.path for source in self.sources.values())
169
+ yield from self.tests
170
+
171
+ def source_root_dir(self, module: str) -> str | None:
172
+ root = module.split(".", maxsplit=1)[0]
173
+ package = self.packages.get(root)
174
+ return package.source_root if package else None
175
+
176
+ def module_to_path(self, module: str, *, package: bool = False) -> str:
177
+ """The expected repo-relative path for ``module``, whether or not it exists."""
178
+ root = (
179
+ self.source_root_dir(module)
180
+ or (self.config.project.source_roots or ["."])[0]
181
+ )
182
+ parts = [p for p in root.strip("/").split("/") if p and p != "."]
183
+ parts.extend(module.split("."))
184
+ return "/".join(parts) + ("/" if package else ".py")
185
+
186
+
187
+ def _contains_python(directory: Path) -> bool:
188
+ return any(True for _ in _python_files(directory))
189
+
190
+
191
+ def _python_files(directory: Path) -> Iterable[Path]:
192
+ for entry in directory.iterdir():
193
+ if entry.is_dir():
194
+ if entry.name in _SKIP_DIRS or entry.name.startswith("."):
195
+ continue
196
+ yield from _python_files(entry)
197
+ elif entry.suffix == ".py":
198
+ yield entry
@@ -0,0 +1,77 @@
1
+ from collections import deque
2
+ from collections.abc import Hashable, Mapping, Set
3
+
4
+
5
+ def strongly_connected_components[T: Hashable](
6
+ adjacency: Mapping[T, Set[T]],
7
+ ) -> list[set[T]]:
8
+ """Tarjan's algorithm, iterative so deep graphs don't hit the recursion limit."""
9
+ index_of: dict[T, int] = {}
10
+ lowlink: dict[T, int] = {}
11
+ on_stack: set[T] = set()
12
+ stack: list[T] = []
13
+ components: list[set[T]] = []
14
+ counter = 0
15
+ nodes = set(adjacency) | {n for targets in adjacency.values() for n in targets}
16
+
17
+ for start in sorted(nodes, key=str):
18
+ if start in index_of:
19
+ continue
20
+ work: list[tuple[T, list[T]]] = [
21
+ (start, sorted(adjacency.get(start, ()), key=str))
22
+ ]
23
+ index_of[start] = lowlink[start] = counter
24
+ counter += 1
25
+ stack.append(start)
26
+ on_stack.add(start)
27
+ while work:
28
+ node, children = work[-1]
29
+ if children:
30
+ child = children.pop(0)
31
+ if child not in index_of:
32
+ index_of[child] = lowlink[child] = counter
33
+ counter += 1
34
+ stack.append(child)
35
+ on_stack.add(child)
36
+ work.append((child, sorted(adjacency.get(child, ()), key=str)))
37
+ elif child in on_stack:
38
+ lowlink[node] = min(lowlink[node], index_of[child])
39
+ continue
40
+ work.pop()
41
+ if work:
42
+ parent = work[-1][0]
43
+ lowlink[parent] = min(lowlink[parent], lowlink[node])
44
+ if lowlink[node] == index_of[node]:
45
+ component: set[T] = set()
46
+ while True:
47
+ member = stack.pop()
48
+ on_stack.discard(member)
49
+ component.add(member)
50
+ if member == node:
51
+ break
52
+ components.append(component)
53
+ return components
54
+
55
+
56
+ def shortest_cycle[T: Hashable](
57
+ start: T, adjacency: Mapping[T, Set[T]], within: Set[T]
58
+ ) -> list[T] | None:
59
+ """Shortest path ``start -> ... -> start`` using only nodes in ``within``."""
60
+ previous: dict[T, T] = {}
61
+ queue: deque[T] = deque([start])
62
+ visited: set[T] = set()
63
+ while queue:
64
+ node = queue.popleft()
65
+ for child in sorted(adjacency.get(node, ()), key=str):
66
+ if child not in within:
67
+ continue
68
+ if child == start:
69
+ path = [node]
70
+ while path[-1] != start:
71
+ path.append(previous[path[-1]])
72
+ return [*reversed(path), start]
73
+ if child not in visited:
74
+ visited.add(child)
75
+ previous[child] = node
76
+ queue.append(child)
77
+ return None