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/_metrics.py
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""The 4 coupling analyses beyond reach/scores (interface width, exposure
|
|
2
|
+
share, entry files, misplaced files), and `render_metrics`, the printer
|
|
3
|
+
for all 5 of `pymodularity metrics`'s sections."""
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from collections.abc import Set as AbstractSet
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from pymodularity._parse.graph import EdgeNames
|
|
11
|
+
from pymodularity._scores import DirScore, FileScore, ancestor_dirs
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class _CountedPath:
|
|
16
|
+
"""A path with a count against it (dependents or dependencies)."""
|
|
17
|
+
|
|
18
|
+
path: Path
|
|
19
|
+
count: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _top_n_by_count(counts: Mapping[Path, int], n: int = 10) -> tuple[_CountedPath, ...]:
|
|
23
|
+
ranked = sorted(counts.items(), key=lambda kv: (-kv[1], str(kv[0])))[:n]
|
|
24
|
+
return tuple(_CountedPath(p, c) for p, c in ranked)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class _FileWidth:
|
|
29
|
+
"""A target file's interface width: distinct names other files import
|
|
30
|
+
from it, and distinct files importing any of those names."""
|
|
31
|
+
|
|
32
|
+
target: Path
|
|
33
|
+
names: int
|
|
34
|
+
importers: int
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _compute_file_widths(
|
|
38
|
+
nodes: AbstractSet[Path], edge_names: EdgeNames
|
|
39
|
+
) -> tuple[_FileWidth, ...]:
|
|
40
|
+
names_by_target: dict[Path, set[str]] = {}
|
|
41
|
+
bare_targets: set[Path] = set()
|
|
42
|
+
importers_by_target: dict[Path, set[Path]] = {}
|
|
43
|
+
for (importer_rel, target_rel), names in edge_names.items():
|
|
44
|
+
if importer_rel == target_rel or target_rel not in nodes:
|
|
45
|
+
continue
|
|
46
|
+
importers_by_target.setdefault(target_rel, set()).add(importer_rel)
|
|
47
|
+
if names:
|
|
48
|
+
names_by_target.setdefault(target_rel, set()).update(names)
|
|
49
|
+
else:
|
|
50
|
+
bare_targets.add(target_rel)
|
|
51
|
+
|
|
52
|
+
widths: list[_FileWidth] = []
|
|
53
|
+
for target in nodes:
|
|
54
|
+
name_count = len(names_by_target.get(target, set())) + (
|
|
55
|
+
1 if target in bare_targets else 0
|
|
56
|
+
)
|
|
57
|
+
importer_count = len(importers_by_target.get(target, set()))
|
|
58
|
+
widths.append(_FileWidth(target, name_count, importer_count))
|
|
59
|
+
return tuple(widths)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _compute_wrapper_dirs(
|
|
63
|
+
nodes: AbstractSet[Path], dirs_with_py: AbstractSet[Path]
|
|
64
|
+
) -> frozenset[Path]:
|
|
65
|
+
"""Directories that hold no .py file of their own and have exactly one
|
|
66
|
+
child directory with Python code, so their file set is identical to
|
|
67
|
+
that child's (and, up a wrapper chain, to the file set of every other
|
|
68
|
+
wrapper in the chain). `metrics`'s directory tables skip these,
|
|
69
|
+
keeping only the deepest directory of each such chain."""
|
|
70
|
+
direct_file_count: dict[Path, int] = {}
|
|
71
|
+
for f in nodes:
|
|
72
|
+
d = f.parent
|
|
73
|
+
direct_file_count[d] = direct_file_count.get(d, 0) + 1
|
|
74
|
+
|
|
75
|
+
children: dict[Path, list[Path]] = {}
|
|
76
|
+
for d in dirs_with_py:
|
|
77
|
+
if d == Path("."):
|
|
78
|
+
continue
|
|
79
|
+
children.setdefault(d.parent, []).append(d)
|
|
80
|
+
|
|
81
|
+
return frozenset(
|
|
82
|
+
d
|
|
83
|
+
for d in dirs_with_py
|
|
84
|
+
if direct_file_count.get(d, 0) == 0 and len(children.get(d, [])) == 1
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True, slots=True)
|
|
89
|
+
class _DirWidth:
|
|
90
|
+
"""A directory's interface width: distinct (target file, name) pairs
|
|
91
|
+
files outside it import from files inside it, and distinct outside
|
|
92
|
+
importing files."""
|
|
93
|
+
|
|
94
|
+
directory: Path
|
|
95
|
+
names: int
|
|
96
|
+
importers: int
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _compute_dir_widths(
|
|
100
|
+
dirs_with_py: AbstractSet[Path], edge_names: EdgeNames
|
|
101
|
+
) -> tuple[_DirWidth, ...]:
|
|
102
|
+
named_pairs: dict[Path, set[tuple[Path, str]]] = {}
|
|
103
|
+
bare_targets: dict[Path, set[Path]] = {}
|
|
104
|
+
importers: dict[Path, set[Path]] = {}
|
|
105
|
+
for (importer_rel, target_rel), names in edge_names.items():
|
|
106
|
+
for directory in ancestor_dirs(target_rel):
|
|
107
|
+
if directory not in dirs_with_py:
|
|
108
|
+
continue
|
|
109
|
+
if importer_rel.is_relative_to(directory):
|
|
110
|
+
continue
|
|
111
|
+
importers.setdefault(directory, set()).add(importer_rel)
|
|
112
|
+
if names:
|
|
113
|
+
named_pairs.setdefault(directory, set()).update(
|
|
114
|
+
(target_rel, name) for name in names
|
|
115
|
+
)
|
|
116
|
+
else:
|
|
117
|
+
bare_targets.setdefault(directory, set()).add(target_rel)
|
|
118
|
+
|
|
119
|
+
widths: list[_DirWidth] = []
|
|
120
|
+
for directory in dirs_with_py:
|
|
121
|
+
name_count = len(named_pairs.get(directory, set())) + len(
|
|
122
|
+
bare_targets.get(directory, set())
|
|
123
|
+
)
|
|
124
|
+
importer_count = len(importers.get(directory, set()))
|
|
125
|
+
widths.append(_DirWidth(directory, name_count, importer_count))
|
|
126
|
+
return tuple(widths)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True, slots=True)
|
|
130
|
+
class _Exposure:
|
|
131
|
+
"""A file's exposure share: how many of its top-level defined names
|
|
132
|
+
(def, async def, class, plain/annotated assignment to a bare name;
|
|
133
|
+
imports don't count) at least one other file actually imports."""
|
|
134
|
+
|
|
135
|
+
path: Path
|
|
136
|
+
used: int
|
|
137
|
+
defined: int
|
|
138
|
+
share: float
|
|
139
|
+
importers: int
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _compute_exposure(
|
|
143
|
+
nodes: AbstractSet[Path],
|
|
144
|
+
edge_names: EdgeNames,
|
|
145
|
+
defined_names: Mapping[Path, AbstractSet[str]],
|
|
146
|
+
) -> Mapping[Path, _Exposure]:
|
|
147
|
+
imported_names: dict[Path, set[str]] = {}
|
|
148
|
+
importers_by_name: dict[Path, dict[str, set[Path]]] = {}
|
|
149
|
+
for (importer_rel, target_rel), names in edge_names.items():
|
|
150
|
+
if not names or importer_rel == target_rel:
|
|
151
|
+
continue
|
|
152
|
+
imported_names.setdefault(target_rel, set()).update(names)
|
|
153
|
+
per_name = importers_by_name.setdefault(target_rel, {})
|
|
154
|
+
for name in names:
|
|
155
|
+
per_name.setdefault(name, set()).add(importer_rel)
|
|
156
|
+
|
|
157
|
+
exposures: dict[Path, _Exposure] = {}
|
|
158
|
+
for f in nodes:
|
|
159
|
+
if f.name == "__init__.py":
|
|
160
|
+
continue
|
|
161
|
+
defined = defined_names.get(f, frozenset())
|
|
162
|
+
used_names = defined & imported_names.get(f, set())
|
|
163
|
+
per_name = importers_by_name.get(f, {})
|
|
164
|
+
importer_set: set[Path] = set()
|
|
165
|
+
for name in used_names:
|
|
166
|
+
importer_set.update(per_name.get(name, set()))
|
|
167
|
+
used = len(used_names)
|
|
168
|
+
total = len(defined)
|
|
169
|
+
share = used / total if total else 0.0
|
|
170
|
+
exposures[f] = _Exposure(f, used, total, share, len(importer_set))
|
|
171
|
+
return exposures
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass(frozen=True, slots=True)
|
|
175
|
+
class _EntryFiles:
|
|
176
|
+
"""A directory's entry-file share: how many of its files at least one
|
|
177
|
+
file outside the directory imports directly, out of all files inside
|
|
178
|
+
it; outside_importers is the distinct outside files doing so."""
|
|
179
|
+
|
|
180
|
+
directory: Path
|
|
181
|
+
entry: int
|
|
182
|
+
files: int
|
|
183
|
+
share: float
|
|
184
|
+
outside_importers: int
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _compute_entry_files(
|
|
188
|
+
dirs_with_py: AbstractSet[Path], nodes: AbstractSet[Path], edge_names: EdgeNames
|
|
189
|
+
) -> Mapping[Path, _EntryFiles]:
|
|
190
|
+
entry_by_dir: dict[Path, set[Path]] = {}
|
|
191
|
+
outside_importers_by_dir: dict[Path, set[Path]] = {}
|
|
192
|
+
for importer_rel, target_rel in edge_names:
|
|
193
|
+
for directory in ancestor_dirs(target_rel):
|
|
194
|
+
if directory not in dirs_with_py:
|
|
195
|
+
continue
|
|
196
|
+
if importer_rel.is_relative_to(directory):
|
|
197
|
+
continue
|
|
198
|
+
entry_by_dir.setdefault(directory, set()).add(target_rel)
|
|
199
|
+
outside_importers_by_dir.setdefault(directory, set()).add(importer_rel)
|
|
200
|
+
|
|
201
|
+
files_by_dir: dict[Path, int] = {}
|
|
202
|
+
for f in nodes:
|
|
203
|
+
for directory in ancestor_dirs(f):
|
|
204
|
+
if directory in dirs_with_py:
|
|
205
|
+
files_by_dir[directory] = files_by_dir.get(directory, 0) + 1
|
|
206
|
+
|
|
207
|
+
out: dict[Path, _EntryFiles] = {}
|
|
208
|
+
for directory in dirs_with_py:
|
|
209
|
+
files = files_by_dir.get(directory, 0)
|
|
210
|
+
entry = len(entry_by_dir.get(directory, set()))
|
|
211
|
+
share = entry / files if files else 0.0
|
|
212
|
+
outside = len(outside_importers_by_dir.get(directory, set()))
|
|
213
|
+
out[directory] = _EntryFiles(directory, entry, files, share, outside)
|
|
214
|
+
return out
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass(frozen=True, slots=True)
|
|
218
|
+
class _Misplaced:
|
|
219
|
+
"""A file whose direct import partners (files it imports plus files
|
|
220
|
+
importing it, from the same file graph the scores use) cluster more
|
|
221
|
+
heavily in some other directory than in its own."""
|
|
222
|
+
|
|
223
|
+
path: Path
|
|
224
|
+
own: int
|
|
225
|
+
other: int
|
|
226
|
+
other_dir: Path
|
|
227
|
+
imported_by: int
|
|
228
|
+
imports: int
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _compute_misplaced(
|
|
232
|
+
nodes: AbstractSet[Path],
|
|
233
|
+
adj: Mapping[Path, AbstractSet[Path]],
|
|
234
|
+
radj: Mapping[Path, AbstractSet[Path]],
|
|
235
|
+
) -> tuple[_Misplaced, ...]:
|
|
236
|
+
results: list[_Misplaced] = []
|
|
237
|
+
for f in nodes:
|
|
238
|
+
importers = radj.get(f, set()) - {f}
|
|
239
|
+
imports_of = adj.get(f, set()) - {f}
|
|
240
|
+
partners = importers | imports_of
|
|
241
|
+
own_dir = f.parent
|
|
242
|
+
own = 0
|
|
243
|
+
other_counts: dict[Path, int] = {}
|
|
244
|
+
for p in partners:
|
|
245
|
+
d = p.parent
|
|
246
|
+
if d == own_dir:
|
|
247
|
+
own += 1
|
|
248
|
+
else:
|
|
249
|
+
other_counts[d] = other_counts.get(d, 0) + 1
|
|
250
|
+
other = 0
|
|
251
|
+
other_dir = Path(".")
|
|
252
|
+
if other_counts:
|
|
253
|
+
best = max(other_counts.values())
|
|
254
|
+
# tie-break deterministically: smallest path among the ties
|
|
255
|
+
other_dir = min(d for d, c in other_counts.items() if c == best)
|
|
256
|
+
other = best
|
|
257
|
+
if other >= 3 and other > own:
|
|
258
|
+
imported_by = sum(1 for p in importers if p.parent == other_dir)
|
|
259
|
+
imports = sum(1 for p in imports_of if p.parent == other_dir)
|
|
260
|
+
results.append(_Misplaced(f, own, other, other_dir, imported_by, imports))
|
|
261
|
+
return tuple(results)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def render_metrics(
|
|
265
|
+
dirs_with_py: AbstractSet[Path],
|
|
266
|
+
edge_names: EdgeNames,
|
|
267
|
+
file_scores: Mapping[Path, FileScore],
|
|
268
|
+
dir_scores: Mapping[Path, DirScore],
|
|
269
|
+
defined_names: Mapping[Path, AbstractSet[str]],
|
|
270
|
+
adj: Mapping[Path, AbstractSet[Path]],
|
|
271
|
+
radj: Mapping[Path, AbstractSet[Path]],
|
|
272
|
+
) -> tuple[str, ...]:
|
|
273
|
+
nodes = set(adj)
|
|
274
|
+
n = len(nodes)
|
|
275
|
+
dependents = {p: fs.dependents for p, fs in file_scores.items()}
|
|
276
|
+
dependencies = {p: fs.dependencies for p, fs in file_scores.items()}
|
|
277
|
+
wrapper_dirs = _compute_wrapper_dirs(nodes, dirs_with_py)
|
|
278
|
+
|
|
279
|
+
lines: list[str] = ["# Coupling report", ""]
|
|
280
|
+
lines.append("## Reach")
|
|
281
|
+
lines.append("")
|
|
282
|
+
lines.append(
|
|
283
|
+
"dependents(f): other files with an import path to f, direct or "
|
|
284
|
+
+ "transitive; dependencies(f): other files f has an import path "
|
|
285
|
+
+ "to; score(f) = dependents(f) x dependencies(f)."
|
|
286
|
+
)
|
|
287
|
+
lines.append("")
|
|
288
|
+
lines.append(f"- N (non-test .py files): {n}")
|
|
289
|
+
lines.append(f"- file-level edges: {len(edge_names)}")
|
|
290
|
+
lines.append("")
|
|
291
|
+
|
|
292
|
+
lines.append("Top 10 by dependents:")
|
|
293
|
+
lines.append("")
|
|
294
|
+
for cp in _top_n_by_count(dependents):
|
|
295
|
+
share = cp.count / n * 100 if n else 0.0
|
|
296
|
+
lines.append(f"- {cp.path}: {cp.count} ({share:.1f}% of N)")
|
|
297
|
+
lines.append("")
|
|
298
|
+
|
|
299
|
+
lines.append("Top 10 by dependencies:")
|
|
300
|
+
lines.append("")
|
|
301
|
+
for cp in _top_n_by_count(dependencies):
|
|
302
|
+
share = cp.count / n * 100 if n else 0.0
|
|
303
|
+
lines.append(f"- {cp.path}: {cp.count} ({share:.1f}% of N)")
|
|
304
|
+
lines.append("")
|
|
305
|
+
|
|
306
|
+
lines.append(
|
|
307
|
+
"All files by score (dependents x dependencies), ties: dependents "
|
|
308
|
+
+ "desc, then path:"
|
|
309
|
+
)
|
|
310
|
+
lines.append("")
|
|
311
|
+
lines.append("| file | dependents | dependencies | score |")
|
|
312
|
+
lines.append("| --- | --- | --- | --- |")
|
|
313
|
+
for fs in sorted(
|
|
314
|
+
file_scores.values(), key=lambda s: (-s.score, -s.dependents, str(s.path))
|
|
315
|
+
):
|
|
316
|
+
lines.append(f"| {fs.path} | {fs.dependents} | {fs.dependencies} | {fs.score} |")
|
|
317
|
+
lines.append("")
|
|
318
|
+
|
|
319
|
+
lines.append(
|
|
320
|
+
"All directories by score (in x out, measured across the "
|
|
321
|
+
+ "directory's own boundary, repo-wide); a directory whose file "
|
|
322
|
+
+ "set is identical to its one child directory's is skipped, "
|
|
323
|
+
+ "keeping only the deepest directory of such a wrapper chain:"
|
|
324
|
+
)
|
|
325
|
+
lines.append("")
|
|
326
|
+
lines.append("| directory | in | out | score |")
|
|
327
|
+
lines.append("| --- | --- | --- | --- |")
|
|
328
|
+
for ds in sorted(
|
|
329
|
+
(s for s in dir_scores.values() if s.path not in wrapper_dirs),
|
|
330
|
+
key=lambda s: (-s.score, str(s.path)),
|
|
331
|
+
):
|
|
332
|
+
directory_label = "repo root" if ds.path == Path(".") else str(ds.path)
|
|
333
|
+
lines.append(
|
|
334
|
+
f"| {directory_label} | {ds.reach_in} | {ds.reach_out} | {ds.score} |"
|
|
335
|
+
)
|
|
336
|
+
lines.append("")
|
|
337
|
+
|
|
338
|
+
lines.append("## Interface width")
|
|
339
|
+
lines.append("")
|
|
340
|
+
lines.append(
|
|
341
|
+
"distinct names other files import from a file or directory "
|
|
342
|
+
+ "(a nameless import, e.g. `import x`, counts the target as 1 "
|
|
343
|
+
+ "name), and the distinct files importing any of them."
|
|
344
|
+
)
|
|
345
|
+
lines.append("")
|
|
346
|
+
|
|
347
|
+
file_widths = sorted(
|
|
348
|
+
_compute_file_widths(nodes, edge_names),
|
|
349
|
+
key=lambda w: (-w.names, str(w.target)),
|
|
350
|
+
)[:10]
|
|
351
|
+
lines.append("Top 10 files by name count:")
|
|
352
|
+
lines.append("")
|
|
353
|
+
for w in file_widths:
|
|
354
|
+
lines.append(f"- {w.target}: {w.names} names, {w.importers} importers")
|
|
355
|
+
lines.append("")
|
|
356
|
+
|
|
357
|
+
dir_widths = sorted(
|
|
358
|
+
(
|
|
359
|
+
w
|
|
360
|
+
for w in _compute_dir_widths(dirs_with_py, edge_names)
|
|
361
|
+
if w.directory not in wrapper_dirs
|
|
362
|
+
),
|
|
363
|
+
key=lambda w: (-w.names, str(w.directory)),
|
|
364
|
+
)[:10]
|
|
365
|
+
lines.append(
|
|
366
|
+
"Top 10 directories by name count (wrapper directories, see " + "above, skipped):"
|
|
367
|
+
)
|
|
368
|
+
lines.append("")
|
|
369
|
+
for w in dir_widths:
|
|
370
|
+
directory_label = "repo root" if w.directory == Path(".") else str(w.directory)
|
|
371
|
+
lines.append(f"- {directory_label}: {w.names} names, {w.importers} importers")
|
|
372
|
+
lines.append("")
|
|
373
|
+
|
|
374
|
+
lines.append("## Exposure share")
|
|
375
|
+
lines.append("")
|
|
376
|
+
lines.append(
|
|
377
|
+
"Per file (`__init__.py` excluded): defined = top-level names it "
|
|
378
|
+
+ "defines (def, async def, class, plain/annotated assignment to a "
|
|
379
|
+
+ "bare name; imports don't count); used = those names at least one "
|
|
380
|
+
+ "other file imports; share = used / defined. Files with defined "
|
|
381
|
+
+ ">= 10, sorted by share desc, then defined desc."
|
|
382
|
+
)
|
|
383
|
+
lines.append("")
|
|
384
|
+
exposures = [
|
|
385
|
+
e
|
|
386
|
+
for e in _compute_exposure(nodes, edge_names, defined_names).values()
|
|
387
|
+
if e.defined >= 10
|
|
388
|
+
]
|
|
389
|
+
exposures.sort(key=lambda e: (-e.share, -e.defined, str(e.path)))
|
|
390
|
+
lines.append("| file | used | defined | share | importing files |")
|
|
391
|
+
lines.append("| --- | --- | --- | --- | --- |")
|
|
392
|
+
for e in exposures:
|
|
393
|
+
lines.append(
|
|
394
|
+
f"| {e.path} | {e.used} | {e.defined} | {e.share * 100:.1f}% "
|
|
395
|
+
+ f"| {e.importers} |"
|
|
396
|
+
)
|
|
397
|
+
lines.append("")
|
|
398
|
+
|
|
399
|
+
lines.append("## Entry files")
|
|
400
|
+
lines.append("")
|
|
401
|
+
lines.append(
|
|
402
|
+
"Per directory (directories with >= 5 files): entry files = files "
|
|
403
|
+
+ "inside the directory at least one file outside it imports "
|
|
404
|
+
+ "directly; share = entry files / files; outside importers = "
|
|
405
|
+
+ "distinct outside files importing anything inside. Sorted by "
|
|
406
|
+
+ "share desc, then outside importers desc. Wrapper directories, "
|
|
407
|
+
+ "see above, skipped."
|
|
408
|
+
)
|
|
409
|
+
lines.append("")
|
|
410
|
+
entries = [
|
|
411
|
+
e
|
|
412
|
+
for e in _compute_entry_files(dirs_with_py, nodes, edge_names).values()
|
|
413
|
+
if e.files >= 5 and e.directory not in wrapper_dirs
|
|
414
|
+
]
|
|
415
|
+
entries.sort(key=lambda e: (-e.share, -e.outside_importers, str(e.directory)))
|
|
416
|
+
lines.append("| directory | entry files | files | share | outside importers |")
|
|
417
|
+
lines.append("| --- | --- | --- | --- | --- |")
|
|
418
|
+
for e in entries:
|
|
419
|
+
directory_label = "repo root" if e.directory == Path(".") else str(e.directory)
|
|
420
|
+
lines.append(
|
|
421
|
+
f"| {directory_label} | {e.entry} | {e.files} | {e.share * 100:.1f}% "
|
|
422
|
+
+ f"| {e.outside_importers} |"
|
|
423
|
+
)
|
|
424
|
+
lines.append("")
|
|
425
|
+
|
|
426
|
+
lines.append("## Misplaced files")
|
|
427
|
+
lines.append("")
|
|
428
|
+
misplaced = sorted(
|
|
429
|
+
_compute_misplaced(nodes, adj, radj),
|
|
430
|
+
key=lambda m: (-(m.other - m.own), str(m.path)),
|
|
431
|
+
)
|
|
432
|
+
lines.append(f"{len(misplaced)} of {n} files misplaced.")
|
|
433
|
+
lines.append("")
|
|
434
|
+
lines.append(
|
|
435
|
+
"partners(f) = files f imports directly plus files importing f "
|
|
436
|
+
+ "directly; own = partners in f's own parent directory; other = "
|
|
437
|
+
+ "the largest count among all other parent directories, naming "
|
|
438
|
+
+ "that directory; misplaced when other >= 3 and other > own. "
|
|
439
|
+
+ "'imported by' = those other-directory partners that import f; "
|
|
440
|
+
+ "'imports' = those other-directory partners f imports; a "
|
|
441
|
+
+ "partner doing both counts in both, so the two may sum to more "
|
|
442
|
+
+ "than other. Sorted by other - own desc, then path."
|
|
443
|
+
)
|
|
444
|
+
lines.append("")
|
|
445
|
+
lines.append("| file | own | other | imported by | imports | other directory |")
|
|
446
|
+
lines.append("| --- | --- | --- | --- | --- | --- |")
|
|
447
|
+
for m in misplaced:
|
|
448
|
+
other_dir_label = "repo root" if m.other_dir == Path(".") else str(m.other_dir)
|
|
449
|
+
lines.append(
|
|
450
|
+
f"| {m.path} | {m.own} | {m.other} | {m.imported_by} | {m.imports} "
|
|
451
|
+
+ f"| {other_dir_label} |"
|
|
452
|
+
)
|
|
453
|
+
lines.append("")
|
|
454
|
+
|
|
455
|
+
return tuple(lines)
|
|
File without changes
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""The module index (`Index`/`build_index`) and import resolution
|
|
2
|
+
(`resolve_exact`/`resolve_relative`, returning the `Resolved | Ambiguous |
|
|
3
|
+
_Unresolved` sum) that `graph._collect_edges` drives one ast walk with."""
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from pymodularity._parse.names import module_comp
|
|
11
|
+
|
|
12
|
+
# dotted module path components, e.g. ("shop", "billing", "pricing")
|
|
13
|
+
_PathParts = tuple[str, ...]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class _ModuleCandidate:
|
|
18
|
+
"""A resolvable module: its file/dir path, and whether it's a namespace
|
|
19
|
+
package directory (True) or a single .py file / __init__.py (False)."""
|
|
20
|
+
|
|
21
|
+
path: Path
|
|
22
|
+
is_namespace_dir: bool
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
ModuleCandidates = Mapping[_PathParts, _ModuleCandidate]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class _SuffixEntry:
|
|
30
|
+
"""A module candidate indexed by dotted suffix."""
|
|
31
|
+
|
|
32
|
+
comp: _PathParts
|
|
33
|
+
path: Path
|
|
34
|
+
is_namespace_dir: bool
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
SuffixIndex = Mapping[_PathParts, tuple[_SuffixEntry, ...]]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class Resolved:
|
|
42
|
+
"""A single unambiguous module match."""
|
|
43
|
+
|
|
44
|
+
path: Path
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class Ambiguous:
|
|
49
|
+
"""Several module matches equally plausible for the importer; the
|
|
50
|
+
caller records a warning and treats the import as unresolved."""
|
|
51
|
+
|
|
52
|
+
candidates: tuple[Path, ...]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class _Unresolved:
|
|
57
|
+
"""No module matched (stdlib, third-party, or a dotted path with no
|
|
58
|
+
candidate in the repo)."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_Resolution = Resolved | Ambiguous | _Unresolved
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class _ScoredCandidate:
|
|
66
|
+
"""A module candidate scored for disambiguation against the importer."""
|
|
67
|
+
|
|
68
|
+
root_depth: int
|
|
69
|
+
is_ancestor: bool
|
|
70
|
+
path: Path
|
|
71
|
+
root: _PathParts
|
|
72
|
+
is_namespace_dir: bool
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True, slots=True)
|
|
76
|
+
class Index:
|
|
77
|
+
"""What a repo's file list indexes into: the directories holding Python
|
|
78
|
+
code, every resolvable module candidate, and that candidate set indexed
|
|
79
|
+
by dotted suffix for lookup."""
|
|
80
|
+
|
|
81
|
+
dirs_with_py: frozenset[Path]
|
|
82
|
+
module_candidates: ModuleCandidates
|
|
83
|
+
suffix_index: SuffixIndex
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def build_index(repo: Path, py_files: Sequence[Path]) -> Index:
|
|
87
|
+
dirs_with_py: set[Path] = set()
|
|
88
|
+
for f in py_files:
|
|
89
|
+
d = f.relative_to(repo).parent
|
|
90
|
+
while True:
|
|
91
|
+
dirs_with_py.add(d)
|
|
92
|
+
if d == Path("."):
|
|
93
|
+
break
|
|
94
|
+
d = d.parent
|
|
95
|
+
|
|
96
|
+
module_candidates: dict[_PathParts, _ModuleCandidate] = {}
|
|
97
|
+
for f in py_files:
|
|
98
|
+
rel = f.relative_to(repo)
|
|
99
|
+
comp = module_comp(rel.parts)
|
|
100
|
+
module_candidates[comp] = _ModuleCandidate(f, False)
|
|
101
|
+
|
|
102
|
+
for d in dirs_with_py:
|
|
103
|
+
comp = () if d == Path(".") else d.parts
|
|
104
|
+
if comp not in module_candidates:
|
|
105
|
+
module_candidates[comp] = _ModuleCandidate(
|
|
106
|
+
repo / d if d != Path(".") else repo, True
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
suffix_index: dict[_PathParts, list[_SuffixEntry]] = {}
|
|
110
|
+
for comp, cand in module_candidates.items():
|
|
111
|
+
for k in range(1, len(comp) + 1):
|
|
112
|
+
suf = comp[-k:]
|
|
113
|
+
suffix_index.setdefault(suf, []).append(
|
|
114
|
+
_SuffixEntry(comp, cand.path, cand.is_namespace_dir)
|
|
115
|
+
)
|
|
116
|
+
frozen_suffix_index: SuffixIndex = {suf: tuple(v) for suf, v in suffix_index.items()}
|
|
117
|
+
|
|
118
|
+
return Index(frozenset(dirs_with_py), module_candidates, frozen_suffix_index)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def path_components(p: Path) -> _PathParts:
|
|
122
|
+
return () if p == Path(".") else p.parts
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _disambiguate(
|
|
126
|
+
cands: Sequence[_SuffixEntry],
|
|
127
|
+
importer_dir_parts: _PathParts,
|
|
128
|
+
suf: _PathParts,
|
|
129
|
+
module_candidates: ModuleCandidates,
|
|
130
|
+
) -> _Resolution:
|
|
131
|
+
uniq: dict[tuple[_PathParts, Path], bool] = {}
|
|
132
|
+
for entry in cands:
|
|
133
|
+
uniq[(entry.comp, entry.path)] = entry.is_namespace_dir
|
|
134
|
+
entries = [_SuffixEntry(comp, path, isd) for (comp, path), isd in uniq.items()]
|
|
135
|
+
|
|
136
|
+
# Python prefers a regular package/module over a namespace-package
|
|
137
|
+
# directory portion, wherever on the path the two sit: if any candidate
|
|
138
|
+
# here is a real .py module or an __init__.py package, drop the
|
|
139
|
+
# namespace-dir candidates before scoring.
|
|
140
|
+
regular = [e for e in entries if not e.is_namespace_dir]
|
|
141
|
+
if regular:
|
|
142
|
+
entries = regular
|
|
143
|
+
|
|
144
|
+
scored: list[_ScoredCandidate] = []
|
|
145
|
+
for entry in entries:
|
|
146
|
+
root = entry.comp[: len(entry.comp) - len(suf)]
|
|
147
|
+
is_ancestor = root == importer_dir_parts[: len(root)]
|
|
148
|
+
scored.append(
|
|
149
|
+
_ScoredCandidate(
|
|
150
|
+
len(root), is_ancestor, entry.path, root, entry.is_namespace_dir
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
ancestors = [s for s in scored if s.is_ancestor]
|
|
155
|
+
if ancestors:
|
|
156
|
+
best_depth = max(s.root_depth for s in ancestors)
|
|
157
|
+
best = [s for s in ancestors if s.root_depth == best_depth]
|
|
158
|
+
return Resolved(best[0].path)
|
|
159
|
+
|
|
160
|
+
# a non-ancestor root is only valid if it is not itself inside a package:
|
|
161
|
+
# a module nested in someone else's package is not importable as a
|
|
162
|
+
# top-level name from another project. This filter only applies to
|
|
163
|
+
# single-file candidates (x.py); package candidates (x/__init__.py or a
|
|
164
|
+
# namespace dir x/) skip it.
|
|
165
|
+
def is_package(root: _PathParts) -> bool:
|
|
166
|
+
cand = module_candidates.get(root)
|
|
167
|
+
return (
|
|
168
|
+
cand is not None
|
|
169
|
+
and not cand.is_namespace_dir
|
|
170
|
+
and cand.path.name == "__init__.py"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def is_single_file_candidate(path: Path, isd: bool) -> bool:
|
|
174
|
+
return not isd and path.name != "__init__.py"
|
|
175
|
+
|
|
176
|
+
non_package_root = [
|
|
177
|
+
s
|
|
178
|
+
for s in scored
|
|
179
|
+
if not (
|
|
180
|
+
is_single_file_candidate(s.path, s.is_namespace_dir) and is_package(s.root)
|
|
181
|
+
)
|
|
182
|
+
]
|
|
183
|
+
if len(non_package_root) == 1:
|
|
184
|
+
return Resolved(non_package_root[0].path)
|
|
185
|
+
|
|
186
|
+
if not non_package_root:
|
|
187
|
+
return _Unresolved()
|
|
188
|
+
|
|
189
|
+
return Ambiguous(tuple(s.path for s in non_package_root))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def resolve_exact(
|
|
193
|
+
dotted_parts: Sequence[str],
|
|
194
|
+
importer_dir_parts: _PathParts,
|
|
195
|
+
suffix_index: SuffixIndex,
|
|
196
|
+
module_candidates: ModuleCandidates,
|
|
197
|
+
) -> _Resolution:
|
|
198
|
+
if not dotted_parts or dotted_parts[0] in sys.stdlib_module_names:
|
|
199
|
+
return _Unresolved()
|
|
200
|
+
suf = tuple(dotted_parts)
|
|
201
|
+
cands = suffix_index.get(suf)
|
|
202
|
+
if not cands:
|
|
203
|
+
return _Unresolved()
|
|
204
|
+
return _disambiguate(cands, importer_dir_parts, suf, module_candidates)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def resolve_relative(
|
|
208
|
+
module_parts: _PathParts,
|
|
209
|
+
level: int,
|
|
210
|
+
importer_dir_parts: _PathParts,
|
|
211
|
+
module_candidates: ModuleCandidates,
|
|
212
|
+
) -> _Resolution:
|
|
213
|
+
drop = level - 1
|
|
214
|
+
if drop > len(importer_dir_parts):
|
|
215
|
+
return _Unresolved()
|
|
216
|
+
target_dir = (
|
|
217
|
+
importer_dir_parts[: len(importer_dir_parts) - drop]
|
|
218
|
+
if drop
|
|
219
|
+
else importer_dir_parts
|
|
220
|
+
)
|
|
221
|
+
comp = target_dir + module_parts
|
|
222
|
+
cand = module_candidates.get(comp)
|
|
223
|
+
if cand is None:
|
|
224
|
+
return _Unresolved()
|
|
225
|
+
return Resolved(cand.path)
|