pymodularity 0.2.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.
- pymodularity/__init__.py +5 -0
- pymodularity/__main__.py +7 -0
- pymodularity/_check.py +161 -0
- pymodularity/_cycles.py +244 -0
- pymodularity/_deps.py +258 -0
- pymodularity/_metrics.py +455 -0
- pymodularity/_parse/__init__.py +0 -0
- pymodularity/_parse/_resolve.py +225 -0
- pymodularity/_parse/graph.py +334 -0
- pymodularity/_parse/names.py +24 -0
- pymodularity/_parse/packages.py +32 -0
- pymodularity/_scores.py +135 -0
- pymodularity/_structure.py +168 -0
- pymodularity/_view_edges.py +71 -0
- pymodularity/_views/__init__.py +0 -0
- pymodularity/_views/_summary.py +252 -0
- pymodularity/_views/render.py +207 -0
- pymodularity/cli.py +276 -0
- pymodularity-0.2.0.dist-info/METADATA +85 -0
- pymodularity-0.2.0.dist-info/RECORD +22 -0
- pymodularity-0.2.0.dist-info/WHEEL +4 -0
- pymodularity-0.2.0.dist-info/entry_points.txt +3 -0
pymodularity/__init__.py
ADDED
pymodularity/__main__.py
ADDED
pymodularity/_check.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""The privacy rules `pymodularity check` enforces (the cycle ban itself
|
|
2
|
+
lives in `_cycles`), and the printing of all check findings."""
|
|
3
|
+
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pymodularity._cycles import CycleFinding
|
|
9
|
+
from pymodularity._parse.graph import EdgeNames
|
|
10
|
+
from pymodularity._parse.names import is_private_name, module_comp
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _owning_package_root(rel: Path, package_roots: Sequence[Path]) -> Path:
|
|
14
|
+
for root in package_roots:
|
|
15
|
+
if root != Path(".") and rel.is_relative_to(root):
|
|
16
|
+
return root
|
|
17
|
+
return Path(".")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _visibility_scope(rel: Path, package_roots: Sequence[Path]) -> Path:
|
|
21
|
+
"""The directory whose subtree may import rel: the private owner of its
|
|
22
|
+
innermost private file/dir component, or the repo root (everyone) if rel
|
|
23
|
+
has no private component (relative to its package root). Several private
|
|
24
|
+
parts each restrict; the narrowest (innermost) one wins."""
|
|
25
|
+
root = _owning_package_root(rel, package_roots)
|
|
26
|
+
sub = rel.relative_to(root) if root != Path(".") else rel
|
|
27
|
+
comp = module_comp(sub.parts)
|
|
28
|
+
for k in range(len(comp) - 1, -1, -1):
|
|
29
|
+
if is_private_name(comp[k]):
|
|
30
|
+
return root / Path(*comp[:k]) if comp[:k] else root
|
|
31
|
+
return Path(".")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class _PrivacyViolation:
|
|
36
|
+
"""An import that reaches a module private to a directory it is not
|
|
37
|
+
under."""
|
|
38
|
+
|
|
39
|
+
importer: Path
|
|
40
|
+
target: Path
|
|
41
|
+
owner: Path
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def find_privacy_violations(
|
|
45
|
+
edge_names: EdgeNames, package_roots: Sequence[Path]
|
|
46
|
+
) -> tuple[_PrivacyViolation, ...]:
|
|
47
|
+
violations: list[_PrivacyViolation] = []
|
|
48
|
+
scope_cache: dict[Path, Path] = {}
|
|
49
|
+
|
|
50
|
+
def scope_of(rel: Path) -> Path:
|
|
51
|
+
if rel not in scope_cache:
|
|
52
|
+
scope_cache[rel] = _visibility_scope(rel, package_roots)
|
|
53
|
+
return scope_cache[rel]
|
|
54
|
+
|
|
55
|
+
for importer_rel, target_rel in edge_names:
|
|
56
|
+
scope = scope_of(target_rel)
|
|
57
|
+
if importer_rel.is_relative_to(scope):
|
|
58
|
+
continue
|
|
59
|
+
violations.append(_PrivacyViolation(importer_rel, target_rel, scope))
|
|
60
|
+
|
|
61
|
+
violations.sort(key=lambda v: (str(v.importer), str(v.target)))
|
|
62
|
+
return tuple(violations)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True, slots=True)
|
|
66
|
+
class _NameOwner:
|
|
67
|
+
"""Where a name imported `from target_rel import _x` is private to."""
|
|
68
|
+
|
|
69
|
+
owner: Path
|
|
70
|
+
owner_is_dir: bool
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _name_owner(target_rel: Path) -> _NameOwner:
|
|
74
|
+
"""A package (`__init__.py` file, or a namespace package directory with
|
|
75
|
+
no `__init__.py`) makes its names visible to files under that directory;
|
|
76
|
+
a single module file makes its names visible to no other file at all."""
|
|
77
|
+
if target_rel.name == "__init__.py":
|
|
78
|
+
return _NameOwner(target_rel.parent, True)
|
|
79
|
+
if target_rel.suffix != ".py":
|
|
80
|
+
return _NameOwner(target_rel, True)
|
|
81
|
+
return _NameOwner(target_rel, False)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class _NameViolation:
|
|
86
|
+
"""An import of a private name (`from m import _x`) that reaches
|
|
87
|
+
outside the directory (package) or file that name is private to."""
|
|
88
|
+
|
|
89
|
+
importer: Path
|
|
90
|
+
target: Path
|
|
91
|
+
name: str
|
|
92
|
+
owner: Path
|
|
93
|
+
owner_is_dir: bool
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def find_name_violations(edge_names: EdgeNames) -> tuple[_NameViolation, ...]:
|
|
97
|
+
violations: list[_NameViolation] = []
|
|
98
|
+
for (importer_rel, target_rel), names in edge_names.items():
|
|
99
|
+
for name in names:
|
|
100
|
+
if not is_private_name(name):
|
|
101
|
+
continue
|
|
102
|
+
owner_info = _name_owner(target_rel)
|
|
103
|
+
if owner_info.owner_is_dir and importer_rel.is_relative_to(owner_info.owner):
|
|
104
|
+
continue
|
|
105
|
+
violations.append(
|
|
106
|
+
_NameViolation(
|
|
107
|
+
importer_rel,
|
|
108
|
+
target_rel,
|
|
109
|
+
name,
|
|
110
|
+
owner_info.owner,
|
|
111
|
+
owner_info.owner_is_dir,
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
violations.sort(key=lambda v: (str(v.importer), str(v.target), v.name))
|
|
115
|
+
return tuple(violations)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def print_check_findings(
|
|
119
|
+
cycle_findings: Sequence[CycleFinding],
|
|
120
|
+
distinct_file_cycles: int,
|
|
121
|
+
privacy_violations: Sequence[_PrivacyViolation],
|
|
122
|
+
name_violations: Sequence[_NameViolation],
|
|
123
|
+
) -> bool:
|
|
124
|
+
"""Prints findings, returns True if any finding exists."""
|
|
125
|
+
any_finding = (
|
|
126
|
+
bool(cycle_findings) or bool(privacy_violations) or bool(name_violations)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
print(
|
|
130
|
+
f"## Cycles: {len(cycle_findings)} in directory views, "
|
|
131
|
+
+ f"{distinct_file_cycles} at file level"
|
|
132
|
+
)
|
|
133
|
+
if cycle_findings:
|
|
134
|
+
print(
|
|
135
|
+
"(a directory view merges each child directory into one node, "
|
|
136
|
+
+ "so a view's cycles need not match the file-level cycles: "
|
|
137
|
+
+ "2 directories can import each other with no file-level cycle, "
|
|
138
|
+
+ "and several file-level cycles can merge into one)"
|
|
139
|
+
)
|
|
140
|
+
for cf in cycle_findings:
|
|
141
|
+
directory_label = "repo root" if cf.directory == Path(".") else str(cf.directory)
|
|
142
|
+
print(f"- {directory_label}: {len(cf.members)} nodes")
|
|
143
|
+
print(f" members: {', '.join(str(m) for m in cf.members)}")
|
|
144
|
+
print(f" example: {' -> '.join(str(n) for n in cf.example)}")
|
|
145
|
+
for edge in cf.example_files:
|
|
146
|
+
print(f" {edge.importer} -> {edge.target}")
|
|
147
|
+
print()
|
|
148
|
+
|
|
149
|
+
total_private = len(privacy_violations) + len(name_violations)
|
|
150
|
+
print(f"## Private-item violations: {total_private} finding(s)")
|
|
151
|
+
for v in privacy_violations:
|
|
152
|
+
print(f"- {v.importer} -> {v.target}: allowed only inside {v.owner}")
|
|
153
|
+
for nv in name_violations:
|
|
154
|
+
scope = "directory" if nv.owner_is_dir else "file"
|
|
155
|
+
print(
|
|
156
|
+
f"- {nv.importer} -> {nv.target}: name {nv.name} private to "
|
|
157
|
+
+ f"{scope} {nv.owner}"
|
|
158
|
+
)
|
|
159
|
+
print()
|
|
160
|
+
|
|
161
|
+
return any_finding
|
pymodularity/_cycles.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Tarjan SCC, on the directory view edges `_view_edges.build_view`
|
|
2
|
+
produces (feeding both `check`'s cycle ban and the Cycles section of
|
|
3
|
+
`graph`'s views), and the file-level cycle count on the raw file graph."""
|
|
4
|
+
|
|
5
|
+
import itertools
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from collections.abc import Set as AbstractSet
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from pymodularity._parse.graph import EdgeKey, EdgeNames
|
|
12
|
+
from pymodularity._view_edges import PairEdges, build_view, compute_inside_nodes
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class _MutualPair:
|
|
17
|
+
"""Two SCC members with edges in both directions, and each direction's
|
|
18
|
+
distinct (importer file, target file) pair count."""
|
|
19
|
+
|
|
20
|
+
a: Path
|
|
21
|
+
b: Path
|
|
22
|
+
a_to_b_files: int
|
|
23
|
+
b_to_a_files: int
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class _SccEdge:
|
|
28
|
+
"""One directed edge inside a strongly connected component."""
|
|
29
|
+
|
|
30
|
+
src: Path
|
|
31
|
+
tgt: Path
|
|
32
|
+
files: int
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class _Scc:
|
|
37
|
+
"""A strongly connected component (size >= 2) among a view's inside
|
|
38
|
+
children, on the edges the view draws."""
|
|
39
|
+
|
|
40
|
+
members: tuple[Path, ...]
|
|
41
|
+
mutual_pairs: tuple[_MutualPair, ...]
|
|
42
|
+
edges: tuple[_SccEdge, ...]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _tarjan_sccs(
|
|
46
|
+
nodes: AbstractSet[Path], adj: Mapping[Path, AbstractSet[Path]]
|
|
47
|
+
) -> tuple[tuple[Path, ...], ...]:
|
|
48
|
+
"""Iterative Tarjan (explicit stack, no recursion) so large views can't
|
|
49
|
+
hit the recursion limit."""
|
|
50
|
+
counter = 0
|
|
51
|
+
index: dict[Path, int] = {}
|
|
52
|
+
lowlink: dict[Path, int] = {}
|
|
53
|
+
on_stack: dict[Path, bool] = {}
|
|
54
|
+
stack: list[Path] = []
|
|
55
|
+
sccs: list[list[Path]] = []
|
|
56
|
+
|
|
57
|
+
for start in sorted(nodes, key=str):
|
|
58
|
+
if start in index:
|
|
59
|
+
continue
|
|
60
|
+
work: list[tuple[Path, list[Path], int]] = [
|
|
61
|
+
(start, sorted(adj.get(start, ()), key=str), 0)
|
|
62
|
+
]
|
|
63
|
+
index[start] = lowlink[start] = counter
|
|
64
|
+
counter += 1
|
|
65
|
+
stack.append(start)
|
|
66
|
+
on_stack[start] = True
|
|
67
|
+
|
|
68
|
+
while work:
|
|
69
|
+
v, children, pos = work[-1]
|
|
70
|
+
advanced = False
|
|
71
|
+
while pos < len(children):
|
|
72
|
+
w = children[pos]
|
|
73
|
+
pos += 1
|
|
74
|
+
if w not in index:
|
|
75
|
+
index[w] = lowlink[w] = counter
|
|
76
|
+
counter += 1
|
|
77
|
+
stack.append(w)
|
|
78
|
+
on_stack[w] = True
|
|
79
|
+
work[-1] = (v, children, pos)
|
|
80
|
+
work.append((w, sorted(adj.get(w, ()), key=str), 0))
|
|
81
|
+
advanced = True
|
|
82
|
+
break
|
|
83
|
+
if on_stack.get(w, False):
|
|
84
|
+
lowlink[v] = min(lowlink[v], index[w])
|
|
85
|
+
if advanced:
|
|
86
|
+
continue
|
|
87
|
+
work[-1] = (v, children, pos)
|
|
88
|
+
work.pop()
|
|
89
|
+
if work:
|
|
90
|
+
parent = work[-1][0]
|
|
91
|
+
lowlink[parent] = min(lowlink[parent], lowlink[v])
|
|
92
|
+
if lowlink[v] == index[v]:
|
|
93
|
+
comp: list[Path] = []
|
|
94
|
+
while True:
|
|
95
|
+
w = stack.pop()
|
|
96
|
+
on_stack[w] = False
|
|
97
|
+
comp.append(w)
|
|
98
|
+
if w == v:
|
|
99
|
+
break
|
|
100
|
+
sccs.append(comp)
|
|
101
|
+
return tuple(tuple(comp) for comp in sccs)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def compute_sccs(
|
|
105
|
+
inside_nodes: AbstractSet[Path], pair_edges: PairEdges
|
|
106
|
+
) -> tuple[_Scc, ...]:
|
|
107
|
+
adj: dict[Path, set[Path]] = {n: set() for n in inside_nodes}
|
|
108
|
+
file_counts: dict[EdgeKey, int] = {}
|
|
109
|
+
for (src, tgt), file_pairs in pair_edges.items():
|
|
110
|
+
if src in inside_nodes and tgt in inside_nodes:
|
|
111
|
+
adj[src].add(tgt)
|
|
112
|
+
file_counts[(src, tgt)] = len(file_pairs)
|
|
113
|
+
|
|
114
|
+
raw = [comp for comp in _tarjan_sccs(inside_nodes, adj) if len(comp) >= 2]
|
|
115
|
+
|
|
116
|
+
result: list[_Scc] = []
|
|
117
|
+
for comp in raw:
|
|
118
|
+
members = sorted(comp, key=str)
|
|
119
|
+
member_set = set(comp)
|
|
120
|
+
mutual_pairs: list[_MutualPair] = []
|
|
121
|
+
seen: set[frozenset[Path]] = set()
|
|
122
|
+
for a in members:
|
|
123
|
+
for b in members:
|
|
124
|
+
if a == b:
|
|
125
|
+
continue
|
|
126
|
+
if (a, b) in file_counts and (b, a) in file_counts:
|
|
127
|
+
key = frozenset((a, b))
|
|
128
|
+
if key in seen:
|
|
129
|
+
continue
|
|
130
|
+
seen.add(key)
|
|
131
|
+
mutual_pairs.append(
|
|
132
|
+
_MutualPair(a, b, file_counts[(a, b)], file_counts[(b, a)])
|
|
133
|
+
)
|
|
134
|
+
mutual_pairs.sort(key=lambda mp: (str(mp.a), str(mp.b)))
|
|
135
|
+
edges = sorted(
|
|
136
|
+
(
|
|
137
|
+
_SccEdge(s, t, c)
|
|
138
|
+
for (s, t), c in file_counts.items()
|
|
139
|
+
if s in member_set and t in member_set
|
|
140
|
+
),
|
|
141
|
+
key=lambda e: (str(e.src), str(e.tgt)),
|
|
142
|
+
)
|
|
143
|
+
result.append(_Scc(tuple(members), tuple(mutual_pairs), tuple(edges)))
|
|
144
|
+
|
|
145
|
+
result.sort(key=lambda scc: (-len(scc.members), str(scc.members[0])))
|
|
146
|
+
return tuple(result)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _find_example_cycle(
|
|
150
|
+
members: Sequence[Path], pair_edges: PairEdges
|
|
151
|
+
) -> tuple[Path, ...]:
|
|
152
|
+
"""One concrete node loop (first == last) through the SCC, via DFS."""
|
|
153
|
+
member_set = set(members)
|
|
154
|
+
adj: dict[Path, list[Path]] = {m: [] for m in members}
|
|
155
|
+
for src, tgt in pair_edges:
|
|
156
|
+
if src in member_set and tgt in member_set and src != tgt:
|
|
157
|
+
adj[src].append(tgt)
|
|
158
|
+
for neighbors in adj.values():
|
|
159
|
+
neighbors.sort(key=str)
|
|
160
|
+
|
|
161
|
+
start = members[0]
|
|
162
|
+
stack: list[Path] = [start]
|
|
163
|
+
visited: set[Path] = {start}
|
|
164
|
+
|
|
165
|
+
def dfs(node: Path) -> bool:
|
|
166
|
+
for nxt in adj[node]:
|
|
167
|
+
if nxt == start and len(stack) > 1:
|
|
168
|
+
return True
|
|
169
|
+
if nxt not in visited:
|
|
170
|
+
visited.add(nxt)
|
|
171
|
+
stack.append(nxt)
|
|
172
|
+
if dfs(nxt):
|
|
173
|
+
return True
|
|
174
|
+
stack.pop()
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
# an SCC guarantees a loop through start, so dfs always finds one
|
|
178
|
+
dfs(start)
|
|
179
|
+
return (*stack, start)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True, slots=True)
|
|
183
|
+
class _FileEdge:
|
|
184
|
+
"""One concrete (importer file, target file) import edge."""
|
|
185
|
+
|
|
186
|
+
importer: Path
|
|
187
|
+
target: Path
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _example_file_edges(
|
|
191
|
+
example: Sequence[Path], pair_edges: PairEdges
|
|
192
|
+
) -> tuple[_FileEdge, ...]:
|
|
193
|
+
out: list[_FileEdge] = []
|
|
194
|
+
for a, b in itertools.pairwise(example):
|
|
195
|
+
file_pairs = pair_edges.get((a, b))
|
|
196
|
+
if file_pairs:
|
|
197
|
+
best = min(file_pairs, key=lambda p: (str(p[0]), str(p[1])))
|
|
198
|
+
out.append(_FileEdge(best[0], best[1]))
|
|
199
|
+
return tuple(out)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass(frozen=True, slots=True)
|
|
203
|
+
class CycleFinding:
|
|
204
|
+
"""One SCC (size >= 2) found in one directory's view, with a concrete
|
|
205
|
+
example loop through file-level edges."""
|
|
206
|
+
|
|
207
|
+
directory: Path
|
|
208
|
+
members: tuple[Path, ...]
|
|
209
|
+
example: tuple[Path, ...]
|
|
210
|
+
example_files: tuple[_FileEdge, ...]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _find_cycle_findings(
|
|
214
|
+
directory: Path, inside_nodes: AbstractSet[Path], pair_edges: PairEdges
|
|
215
|
+
) -> tuple[CycleFinding, ...]:
|
|
216
|
+
findings: list[CycleFinding] = []
|
|
217
|
+
for scc in compute_sccs(inside_nodes, pair_edges):
|
|
218
|
+
example = _find_example_cycle(scc.members, pair_edges)
|
|
219
|
+
findings.append(
|
|
220
|
+
CycleFinding(
|
|
221
|
+
directory, scc.members, example, _example_file_edges(example, pair_edges)
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
return tuple(findings)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def count_file_level_cycles(adj: Mapping[Path, AbstractSet[Path]]) -> int:
|
|
228
|
+
"""Distinct cycles on the raw file-level graph, ignoring any directory
|
|
229
|
+
view's aggregation -- the number of genuinely separate root causes."""
|
|
230
|
+
sccs = _tarjan_sccs(set(adj), adj)
|
|
231
|
+
return sum(1 for comp in sccs if len(comp) >= 2)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def find_all_cycle_findings(
|
|
235
|
+
dirs_with_py: AbstractSet[Path], edge_names: EdgeNames
|
|
236
|
+
) -> tuple[CycleFinding, ...]:
|
|
237
|
+
"""The cycle ban `check` enforces: every directory's view, scanned for
|
|
238
|
+
SCCs (size >= 2) among its inside children."""
|
|
239
|
+
findings: list[CycleFinding] = []
|
|
240
|
+
for directory in sorted(dirs_with_py, key=str):
|
|
241
|
+
pair_edges = build_view(directory, edge_names)
|
|
242
|
+
inside_nodes = compute_inside_nodes(directory, pair_edges)
|
|
243
|
+
findings.extend(_find_cycle_findings(directory, inside_nodes, pair_edges))
|
|
244
|
+
return tuple(findings)
|
pymodularity/_deps.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""The dependency-declaration rule `pymodularity check` enforces: shells out
|
|
2
|
+
to deptry, pinned to one exact version, once per package root (a directory
|
|
3
|
+
with a pyproject.toml [project] table found by `_parse.packages`), via
|
|
4
|
+
`uv run --directory <pkg> --with deptry==<pin> --frozen deptry . --json-output
|
|
5
|
+
<tmp>`. A package is skipped (not a failure) if it declares no runtime
|
|
6
|
+
dependencies, or has no uv.lock of its own (uv run --frozen fails with exit
|
|
7
|
+
2 and "Unable to find lockfile"). Any other deptry failure, or JSON that
|
|
8
|
+
does not parse, is a finding-level failure. `uv` missing from PATH is a
|
|
9
|
+
hard error (exit 2), never a silent skip.
|
|
10
|
+
|
|
11
|
+
Imported only by `cli`; must not import `_check`, `_metrics`, `_scores` or
|
|
12
|
+
`_views` (this rule is independent of the import-graph checks)."""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import tempfile
|
|
19
|
+
import tomllib
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import cast
|
|
24
|
+
|
|
25
|
+
from pymodularity._parse.packages import find_package_roots
|
|
26
|
+
|
|
27
|
+
_DEPTRY_VERSION = "0.25.1"
|
|
28
|
+
|
|
29
|
+
_NO_LOCKFILE_MARKER = "Unable to find lockfile"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class _KnownPosition:
|
|
34
|
+
line: int
|
|
35
|
+
column: int
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class _UnknownPosition:
|
|
40
|
+
"""deptry reports a finding with no file position (line/column null in
|
|
41
|
+
its JSON)."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_Position = _KnownPosition | _UnknownPosition
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class _DepFinding:
|
|
49
|
+
"""One deptry finding, scoped to the package it was found in."""
|
|
50
|
+
|
|
51
|
+
pkg: Path
|
|
52
|
+
file: str
|
|
53
|
+
position: _Position
|
|
54
|
+
code: str
|
|
55
|
+
message: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class _Skipped:
|
|
60
|
+
pkg: Path
|
|
61
|
+
reason: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class _Failed:
|
|
66
|
+
"""deptry failed to run on a package, or its JSON output did not
|
|
67
|
+
parse."""
|
|
68
|
+
|
|
69
|
+
pkg: Path
|
|
70
|
+
stderr_tail: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class _DepsResult:
|
|
75
|
+
findings: tuple[_DepFinding, ...] = ()
|
|
76
|
+
skipped: tuple[_Skipped, ...] = field(default_factory=tuple)
|
|
77
|
+
failed: tuple[_Failed, ...] = field(default_factory=tuple)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _has_runtime_dependencies(pyproject: Path) -> bool:
|
|
81
|
+
with pyproject.open("rb") as f:
|
|
82
|
+
data = tomllib.load(f)
|
|
83
|
+
project = cast(dict[str, object], data.get("project", {}))
|
|
84
|
+
deps = project.get("dependencies", [])
|
|
85
|
+
return isinstance(deps, list) and len(cast(list[object], deps)) > 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _nested_exclusions(pkg: Path, package_roots: Sequence[Path]) -> tuple[Path, ...]:
|
|
89
|
+
"""Subdirectories of pkg that are themselves package roots, relative to
|
|
90
|
+
pkg, so a package's own scan does not re-scan a nested package."""
|
|
91
|
+
nested: list[Path] = []
|
|
92
|
+
for other in package_roots:
|
|
93
|
+
if other == pkg:
|
|
94
|
+
continue
|
|
95
|
+
try:
|
|
96
|
+
rel = other.relative_to(pkg)
|
|
97
|
+
except ValueError:
|
|
98
|
+
continue
|
|
99
|
+
nested.append(rel)
|
|
100
|
+
return tuple(nested)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _position_from(line: object, column: object) -> _Position:
|
|
104
|
+
if isinstance(line, int) and isinstance(column, int):
|
|
105
|
+
return _KnownPosition(line, column)
|
|
106
|
+
return _UnknownPosition()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True, slots=True)
|
|
110
|
+
class _ParseError:
|
|
111
|
+
"""deptry's JSON output did not have the shape this parser expects."""
|
|
112
|
+
|
|
113
|
+
message: str
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _parse_deptry_json(raw: str, pkg: Path) -> tuple[_DepFinding, ...] | _ParseError:
|
|
117
|
+
try:
|
|
118
|
+
data = cast(object, json.loads(raw))
|
|
119
|
+
except json.JSONDecodeError as exc:
|
|
120
|
+
return _ParseError(str(exc))
|
|
121
|
+
if not isinstance(data, list):
|
|
122
|
+
return _ParseError("expected a JSON list")
|
|
123
|
+
findings: list[_DepFinding] = []
|
|
124
|
+
for item in cast(list[object], data):
|
|
125
|
+
if not isinstance(item, dict):
|
|
126
|
+
return _ParseError("expected a JSON object per finding")
|
|
127
|
+
item = cast(dict[str, object], item)
|
|
128
|
+
err = cast(dict[str, object], item.get("error", {}))
|
|
129
|
+
loc = cast(dict[str, object], item.get("location", {}))
|
|
130
|
+
code = err.get("code", "?")
|
|
131
|
+
message = err.get("message", "")
|
|
132
|
+
file = loc.get("file", "?")
|
|
133
|
+
if (
|
|
134
|
+
not isinstance(code, str)
|
|
135
|
+
or not isinstance(message, str)
|
|
136
|
+
or not isinstance(file, str)
|
|
137
|
+
):
|
|
138
|
+
return _ParseError("expected str fields in error/location")
|
|
139
|
+
findings.append(
|
|
140
|
+
_DepFinding(
|
|
141
|
+
pkg=pkg,
|
|
142
|
+
file=file,
|
|
143
|
+
position=_position_from(loc.get("line"), loc.get("column")),
|
|
144
|
+
code=code,
|
|
145
|
+
message=message,
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
return tuple(findings)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _run_deptry_on(
|
|
152
|
+
repo: Path, pkg: Path, package_roots: Sequence[Path], tmp_dir: Path
|
|
153
|
+
) -> _Skipped | _Failed | tuple[_DepFinding, ...]:
|
|
154
|
+
pkg_abs = repo / pkg
|
|
155
|
+
cmd = [
|
|
156
|
+
"uv",
|
|
157
|
+
"run",
|
|
158
|
+
"--directory",
|
|
159
|
+
str(pkg_abs),
|
|
160
|
+
"--with",
|
|
161
|
+
f"deptry=={_DEPTRY_VERSION}",
|
|
162
|
+
"--frozen",
|
|
163
|
+
"deptry",
|
|
164
|
+
".",
|
|
165
|
+
"--no-ansi",
|
|
166
|
+
]
|
|
167
|
+
for rel in _nested_exclusions(pkg, package_roots):
|
|
168
|
+
pattern = "^" + re.escape(rel.as_posix()) + "(/|$)"
|
|
169
|
+
cmd += ["--extend-exclude", pattern]
|
|
170
|
+
json_out = tmp_dir / f"{pkg.as_posix().replace('/', '__') or 'root'}.json"
|
|
171
|
+
cmd += ["--json-output", str(json_out)]
|
|
172
|
+
|
|
173
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
174
|
+
|
|
175
|
+
if json_out.exists():
|
|
176
|
+
parsed = _parse_deptry_json(json_out.read_text(), pkg)
|
|
177
|
+
if isinstance(parsed, _ParseError):
|
|
178
|
+
return _Failed(pkg, parsed.message)
|
|
179
|
+
return parsed
|
|
180
|
+
if proc.returncode == 2 and _NO_LOCKFILE_MARKER in proc.stderr:
|
|
181
|
+
return _Skipped(pkg, "no uv.lock found (uv run --frozen needs one)")
|
|
182
|
+
tail = "\n".join(proc.stderr.strip().splitlines()[-20:]) or "(no stderr output)"
|
|
183
|
+
return _Failed(pkg, tail)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass(frozen=True, slots=True)
|
|
187
|
+
class UvMissing:
|
|
188
|
+
"""`uv` is not on PATH; the dependency-declaration rule cannot run at
|
|
189
|
+
all, never a silent skip."""
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def run_dependency_checks(repo: Path) -> _DepsResult | UvMissing:
|
|
193
|
+
"""Scans every package root under repo with deptry, one uv-managed
|
|
194
|
+
environment per package."""
|
|
195
|
+
if shutil.which("uv") is None:
|
|
196
|
+
return UvMissing()
|
|
197
|
+
|
|
198
|
+
package_roots = find_package_roots(repo)
|
|
199
|
+
findings: list[_DepFinding] = []
|
|
200
|
+
skipped: list[_Skipped] = []
|
|
201
|
+
failed: list[_Failed] = []
|
|
202
|
+
|
|
203
|
+
with tempfile.TemporaryDirectory(prefix="pymodularity_deps_") as tmp:
|
|
204
|
+
tmp_dir = Path(tmp)
|
|
205
|
+
for pkg in package_roots:
|
|
206
|
+
pyproject = repo / pkg / "pyproject.toml"
|
|
207
|
+
if not _has_runtime_dependencies(pyproject):
|
|
208
|
+
skipped.append(_Skipped(pkg, "declares no dependencies"))
|
|
209
|
+
continue
|
|
210
|
+
outcome = _run_deptry_on(repo, pkg, package_roots, tmp_dir)
|
|
211
|
+
if isinstance(outcome, _Skipped):
|
|
212
|
+
skipped.append(outcome)
|
|
213
|
+
elif isinstance(outcome, _Failed):
|
|
214
|
+
failed.append(outcome)
|
|
215
|
+
else:
|
|
216
|
+
findings.extend(outcome)
|
|
217
|
+
|
|
218
|
+
findings.sort(key=lambda f: (str(f.pkg), f.file, _sort_pos(f.position)))
|
|
219
|
+
skipped.sort(key=lambda s: str(s.pkg))
|
|
220
|
+
failed.sort(key=lambda f: str(f.pkg))
|
|
221
|
+
return _DepsResult(
|
|
222
|
+
findings=tuple(findings), skipped=tuple(skipped), failed=tuple(failed)
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _sort_pos(position: _Position):
|
|
227
|
+
"""Sort key for a position: (0, 0) for the unknown case sorts first,
|
|
228
|
+
same ordering `deptry_all.py` uses. No return-type annotation here
|
|
229
|
+
deliberately -- `tuple[int, int]` as a *named* return type is the
|
|
230
|
+
anti-pattern `prefer-named-types-over-tuples` flags; an unlabeled
|
|
231
|
+
sort-key return isn't."""
|
|
232
|
+
if isinstance(position, _KnownPosition):
|
|
233
|
+
return (position.line, position.column)
|
|
234
|
+
return (0, 0)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def print_deps_findings(result: _DepsResult) -> bool:
|
|
238
|
+
"""Prints the dependency-declaration section, returns True if any
|
|
239
|
+
finding or failure exists (failures are finding-level, same exit
|
|
240
|
+
behavior as a finding)."""
|
|
241
|
+
any_finding = bool(result.findings) or bool(result.failed)
|
|
242
|
+
|
|
243
|
+
print(f"## Dependency declarations: {len(result.findings)} finding(s)")
|
|
244
|
+
for f in result.findings:
|
|
245
|
+
loc = (
|
|
246
|
+
f"{f.pkg}/{f.file}:{f.position.line}:{f.position.column}"
|
|
247
|
+
if isinstance(f.position, _KnownPosition)
|
|
248
|
+
else f"{f.pkg}/{f.file}"
|
|
249
|
+
)
|
|
250
|
+
print(f"{loc}: {f.code} {f.message}")
|
|
251
|
+
for failure in result.failed:
|
|
252
|
+
print(f"- {failure.pkg}: deptry failed to run or its output did not parse")
|
|
253
|
+
print(failure.stderr_tail)
|
|
254
|
+
for s in result.skipped:
|
|
255
|
+
print(f"- {s.pkg}: skipped ({s.reason})")
|
|
256
|
+
print()
|
|
257
|
+
|
|
258
|
+
return any_finding
|