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
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""File discovery and file-level edge collection: build the whole-repo
|
|
2
|
+
Python import graph with ast (stdlib only), driving `_resolve`'s index and
|
|
3
|
+
resolution functions through one ast walk per file, plus the
|
|
4
|
+
`build_graph` entry point every subcommand calls. `EdgeKey`/`EdgeNames`,
|
|
5
|
+
the file-level edge types every downstream module shares, live here too."""
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from collections.abc import Set as AbstractSet
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from pymodularity._parse._resolve import (
|
|
16
|
+
Ambiguous,
|
|
17
|
+
Index,
|
|
18
|
+
ModuleCandidates,
|
|
19
|
+
Resolved,
|
|
20
|
+
SuffixIndex,
|
|
21
|
+
build_index,
|
|
22
|
+
path_components,
|
|
23
|
+
resolve_exact,
|
|
24
|
+
resolve_relative,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# (importer_rel, target_rel) file-level import edge
|
|
28
|
+
EdgeKey = tuple[Path, Path]
|
|
29
|
+
EdgeNames = Mapping[EdgeKey, frozenset[str]]
|
|
30
|
+
# an ambiguous-import warning: the importing file and its candidate targets
|
|
31
|
+
_AmbiguityWarning = tuple[Path, tuple[Path, ...]]
|
|
32
|
+
# a resolved absolute/relative import: target file, imported names (if any)
|
|
33
|
+
_ResolvedImport = tuple[Path, frozenset[str]]
|
|
34
|
+
|
|
35
|
+
_SKIP_DIRS = frozenset(
|
|
36
|
+
{
|
|
37
|
+
".git",
|
|
38
|
+
".venv",
|
|
39
|
+
"venv",
|
|
40
|
+
"node_modules",
|
|
41
|
+
"__pycache__",
|
|
42
|
+
"build",
|
|
43
|
+
"dist",
|
|
44
|
+
"tests",
|
|
45
|
+
"test",
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def should_skip_dir(name: str) -> bool:
|
|
51
|
+
return name in _SKIP_DIRS or name.startswith(".")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _is_test_file(name: str) -> bool:
|
|
55
|
+
return name == "conftest.py" or name.startswith("test_") or name.endswith("_test.py")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _find_py_files(repo: Path) -> tuple[Path, ...]:
|
|
59
|
+
files: list[Path] = []
|
|
60
|
+
for dirpath, dirnames, filenames in os.walk(repo):
|
|
61
|
+
dirnames[:] = [d for d in dirnames if not should_skip_dir(d)]
|
|
62
|
+
for fn in filenames:
|
|
63
|
+
if fn.endswith(".py") and not _is_test_file(fn):
|
|
64
|
+
files.append(Path(dirpath) / fn)
|
|
65
|
+
return tuple(files)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _assign_target_names(target: ast.expr) -> tuple[str, ...]:
|
|
69
|
+
"""Names bound by one assignment target, recursing into tuple/list
|
|
70
|
+
unpacking (including a starred element)."""
|
|
71
|
+
if isinstance(target, ast.Name):
|
|
72
|
+
return (target.id,)
|
|
73
|
+
if isinstance(target, (ast.Tuple, ast.List)):
|
|
74
|
+
names: list[str] = []
|
|
75
|
+
for elt in target.elts:
|
|
76
|
+
inner = elt.value if isinstance(elt, ast.Starred) else elt
|
|
77
|
+
names.extend(_assign_target_names(inner))
|
|
78
|
+
return tuple(names)
|
|
79
|
+
return ()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _names_from_stmt(stmt: ast.stmt) -> tuple[str, ...]:
|
|
83
|
+
"""Names one statement defines: def/async def/class, plain or annotated
|
|
84
|
+
assignment (including tuple-unpacking), `type X = ...`, or the same
|
|
85
|
+
recursively inside an `if`/`try` block's branches (body, elif/else,
|
|
86
|
+
except handlers, finally). Imports never count."""
|
|
87
|
+
if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
88
|
+
return (stmt.name,)
|
|
89
|
+
if isinstance(stmt, ast.Assign):
|
|
90
|
+
names: list[str] = []
|
|
91
|
+
for target in stmt.targets:
|
|
92
|
+
names.extend(_assign_target_names(target))
|
|
93
|
+
return tuple(names)
|
|
94
|
+
if isinstance(stmt, ast.AnnAssign):
|
|
95
|
+
return _assign_target_names(stmt.target)
|
|
96
|
+
if type(stmt).__name__ == "TypeAlias":
|
|
97
|
+
# `type X = ...` (py3.12+); accessed by name, not ast.TypeAlias,
|
|
98
|
+
# since this project's declared pythonVersion (3.11) doesn't know
|
|
99
|
+
# that attribute, and ast.parse itself only ever yields this node
|
|
100
|
+
# when running under a 3.12+ interpreter.
|
|
101
|
+
name_node: object = getattr(stmt, "name", None)
|
|
102
|
+
return (name_node.id,) if isinstance(name_node, ast.Name) else ()
|
|
103
|
+
if isinstance(stmt, ast.If):
|
|
104
|
+
return tuple(n for s in (*stmt.body, *stmt.orelse) for n in _names_from_stmt(s))
|
|
105
|
+
if isinstance(stmt, ast.Try):
|
|
106
|
+
branches = (*stmt.body, *stmt.orelse, *stmt.finalbody)
|
|
107
|
+
names = [n for s in branches for n in _names_from_stmt(s)]
|
|
108
|
+
for handler in stmt.handlers:
|
|
109
|
+
names.extend(n for s in handler.body for n in _names_from_stmt(s))
|
|
110
|
+
return tuple(names)
|
|
111
|
+
return ()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _top_level_defined_names(tree: ast.Module) -> frozenset[str]:
|
|
115
|
+
"""Names this module defines at top level: def, async def, class, plain
|
|
116
|
+
or annotated assignment (including tuple-unpacking), `type X = ...`, and
|
|
117
|
+
the same nested in a top-level `if`/`try` block. Imports don't count."""
|
|
118
|
+
defined: set[str] = set()
|
|
119
|
+
for stmt in tree.body:
|
|
120
|
+
defined.update(_names_from_stmt(stmt))
|
|
121
|
+
return frozenset(defined)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _registered_names_from_stmt(stmt: ast.stmt) -> tuple[str, ...]:
|
|
125
|
+
"""Names of top-level functions (not classes) a decorator registers --
|
|
126
|
+
FastAPI routes, Typer/Click commands, Dagster assets, pytest fixtures --
|
|
127
|
+
including the same nested in a top-level `if`/`try` block. Any
|
|
128
|
+
decorator counts; there's no list of framework names."""
|
|
129
|
+
if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
130
|
+
return (stmt.name,) if stmt.decorator_list else ()
|
|
131
|
+
if isinstance(stmt, ast.If):
|
|
132
|
+
return tuple(
|
|
133
|
+
n for s in (*stmt.body, *stmt.orelse) for n in _registered_names_from_stmt(s)
|
|
134
|
+
)
|
|
135
|
+
if isinstance(stmt, ast.Try):
|
|
136
|
+
branches = (*stmt.body, *stmt.orelse, *stmt.finalbody)
|
|
137
|
+
names = [n for s in branches for n in _registered_names_from_stmt(s)]
|
|
138
|
+
for handler in stmt.handlers:
|
|
139
|
+
names.extend(n for s in handler.body for n in _registered_names_from_stmt(s))
|
|
140
|
+
return tuple(names)
|
|
141
|
+
return ()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _top_level_registered_names(tree: ast.Module) -> frozenset[str]:
|
|
145
|
+
"""Names this module's top-level decorated functions define (not
|
|
146
|
+
classes -- `@dataclass` sits on most models), including the same
|
|
147
|
+
nested in a top-level `if`/`try` block."""
|
|
148
|
+
registered: set[str] = set()
|
|
149
|
+
for stmt in tree.body:
|
|
150
|
+
registered.update(_registered_names_from_stmt(stmt))
|
|
151
|
+
return frozenset(registered)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True, slots=True)
|
|
155
|
+
class _CollectedEdges:
|
|
156
|
+
"""What one parse of a repo's files collects: file-level import edges
|
|
157
|
+
(with their imported names), the ambiguous-import warnings raised along
|
|
158
|
+
the way, each file's top-level defined names, and each file's
|
|
159
|
+
decorator-registered names (a subset of defined_names)."""
|
|
160
|
+
|
|
161
|
+
edge_names: EdgeNames
|
|
162
|
+
warnings: tuple[_AmbiguityWarning, ...]
|
|
163
|
+
defined_names: Mapping[Path, frozenset[str]]
|
|
164
|
+
registered_names: Mapping[Path, frozenset[str]]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _collect_edges(
|
|
168
|
+
repo: Path,
|
|
169
|
+
py_files: Sequence[Path],
|
|
170
|
+
module_candidates: ModuleCandidates,
|
|
171
|
+
suffix_index: SuffixIndex,
|
|
172
|
+
) -> _CollectedEdges:
|
|
173
|
+
# (importer_rel, target_rel) -> set of imported names (empty = no names,
|
|
174
|
+
# e.g. a plain "import x.y" or a "from a import b" where b is itself the
|
|
175
|
+
# resolved submodule rather than a name defined inside it)
|
|
176
|
+
edge_names: dict[EdgeKey, set[str]] = {}
|
|
177
|
+
warnings: list[_AmbiguityWarning] = []
|
|
178
|
+
defined_names: dict[Path, frozenset[str]] = {}
|
|
179
|
+
registered_names: dict[Path, frozenset[str]] = {}
|
|
180
|
+
for f in sorted(py_files):
|
|
181
|
+
rel = f.relative_to(repo)
|
|
182
|
+
try:
|
|
183
|
+
text = f.read_text(encoding="utf-8")
|
|
184
|
+
tree = ast.parse(text, filename=str(f))
|
|
185
|
+
except (SyntaxError, UnicodeDecodeError) as e:
|
|
186
|
+
print(f"warning: skipping {rel}: {e}", file=sys.stderr)
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
defined_names[rel] = _top_level_defined_names(tree)
|
|
190
|
+
registered_names[rel] = _top_level_registered_names(tree)
|
|
191
|
+
importer_dir_parts = path_components(rel.parent)
|
|
192
|
+
targets: list[_ResolvedImport] = []
|
|
193
|
+
for node in ast.walk(tree):
|
|
194
|
+
if isinstance(node, ast.Import):
|
|
195
|
+
for alias in node.names:
|
|
196
|
+
dotted = alias.name.split(".")
|
|
197
|
+
res = resolve_exact(
|
|
198
|
+
dotted, importer_dir_parts, suffix_index, module_candidates
|
|
199
|
+
)
|
|
200
|
+
if isinstance(res, Ambiguous):
|
|
201
|
+
warnings.append((rel, res.candidates))
|
|
202
|
+
elif isinstance(res, Resolved):
|
|
203
|
+
targets.append((res.path, frozenset()))
|
|
204
|
+
elif isinstance(node, ast.ImportFrom):
|
|
205
|
+
if node.level and node.level > 0:
|
|
206
|
+
requests: list[tuple[tuple[str, ...], frozenset[str]]]
|
|
207
|
+
if node.module:
|
|
208
|
+
requests = [
|
|
209
|
+
(
|
|
210
|
+
tuple(node.module.split(".")),
|
|
211
|
+
frozenset(alias.name for alias in node.names),
|
|
212
|
+
)
|
|
213
|
+
]
|
|
214
|
+
else:
|
|
215
|
+
requests = [((alias.name,), frozenset()) for alias in node.names]
|
|
216
|
+
for module_parts, names_here in requests:
|
|
217
|
+
res = resolve_relative(
|
|
218
|
+
module_parts,
|
|
219
|
+
node.level,
|
|
220
|
+
importer_dir_parts,
|
|
221
|
+
module_candidates,
|
|
222
|
+
)
|
|
223
|
+
if isinstance(res, Resolved):
|
|
224
|
+
targets.append((res.path, names_here))
|
|
225
|
+
else:
|
|
226
|
+
module_parts = node.module.split(".") if node.module else []
|
|
227
|
+
for alias in node.names:
|
|
228
|
+
res = resolve_exact(
|
|
229
|
+
module_parts + [alias.name],
|
|
230
|
+
importer_dir_parts,
|
|
231
|
+
suffix_index,
|
|
232
|
+
module_candidates,
|
|
233
|
+
)
|
|
234
|
+
if isinstance(res, Ambiguous):
|
|
235
|
+
warnings.append((rel, res.candidates))
|
|
236
|
+
continue
|
|
237
|
+
if isinstance(res, Resolved):
|
|
238
|
+
targets.append((res.path, frozenset()))
|
|
239
|
+
continue
|
|
240
|
+
res = resolve_exact(
|
|
241
|
+
module_parts,
|
|
242
|
+
importer_dir_parts,
|
|
243
|
+
suffix_index,
|
|
244
|
+
module_candidates,
|
|
245
|
+
)
|
|
246
|
+
if isinstance(res, Ambiguous):
|
|
247
|
+
warnings.append((rel, res.candidates))
|
|
248
|
+
elif isinstance(res, Resolved):
|
|
249
|
+
targets.append((res.path, frozenset({alias.name})))
|
|
250
|
+
|
|
251
|
+
for t, names_here in targets:
|
|
252
|
+
trel = t.relative_to(repo)
|
|
253
|
+
if trel == rel:
|
|
254
|
+
continue
|
|
255
|
+
edge_names.setdefault((rel, trel), set()).update(names_here)
|
|
256
|
+
|
|
257
|
+
frozen_edge_names: EdgeNames = {k: frozenset(v) for k, v in edge_names.items()}
|
|
258
|
+
return _CollectedEdges(
|
|
259
|
+
frozen_edge_names,
|
|
260
|
+
tuple(warnings),
|
|
261
|
+
defined_names,
|
|
262
|
+
registered_names,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@dataclass(frozen=True, slots=True)
|
|
267
|
+
class _FileGraph:
|
|
268
|
+
"""The raw file-level import graph: forward adjacency and its reverse."""
|
|
269
|
+
|
|
270
|
+
adj: Mapping[Path, frozenset[Path]]
|
|
271
|
+
radj: Mapping[Path, frozenset[Path]]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _build_file_graph(nodes: AbstractSet[Path], edge_names: EdgeNames) -> _FileGraph:
|
|
275
|
+
"""Same edges `check`'s cycle rule uses: no self-edges (_collect_edges
|
|
276
|
+
already drops those), duplicates already collapsed by edge_names' dict
|
|
277
|
+
keys."""
|
|
278
|
+
adj: dict[Path, set[Path]] = {n: set() for n in nodes}
|
|
279
|
+
radj: dict[Path, set[Path]] = {n: set() for n in nodes}
|
|
280
|
+
for importer_rel, target_rel in edge_names:
|
|
281
|
+
if importer_rel in adj and target_rel in adj and importer_rel != target_rel:
|
|
282
|
+
adj[importer_rel].add(target_rel)
|
|
283
|
+
radj[target_rel].add(importer_rel)
|
|
284
|
+
frozen_adj = {n: frozenset(s) for n, s in adj.items()}
|
|
285
|
+
frozen_radj = {n: frozenset(s) for n, s in radj.items()}
|
|
286
|
+
return _FileGraph(frozen_adj, frozen_radj)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@dataclass(frozen=True, slots=True)
|
|
290
|
+
class _RepoGraph:
|
|
291
|
+
"""Every structure every subcommand needs, built once from one parse of
|
|
292
|
+
`repo`: the file list, the directories holding Python code, the
|
|
293
|
+
file-level import edges (and their imported names), each file's
|
|
294
|
+
top-level defined names, each file's decorator-registered names, and
|
|
295
|
+
the forward/reverse file adjacency."""
|
|
296
|
+
|
|
297
|
+
py_files: tuple[Path, ...]
|
|
298
|
+
dirs_with_py: frozenset[Path]
|
|
299
|
+
edge_names: EdgeNames
|
|
300
|
+
defined_names: Mapping[Path, frozenset[str]]
|
|
301
|
+
registered_names: Mapping[Path, frozenset[str]]
|
|
302
|
+
adj: Mapping[Path, frozenset[Path]]
|
|
303
|
+
radj: Mapping[Path, frozenset[Path]]
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def build_graph(repo: Path) -> _RepoGraph:
|
|
307
|
+
"""Parses `repo` once and builds every structure every subcommand needs.
|
|
308
|
+
Prints ambiguous-import warnings to stderr along the way. Reach and
|
|
309
|
+
file/directory scores are not computed here -- `check` never uses
|
|
310
|
+
them, so `graph` and `metrics` compute them in their own run_*
|
|
311
|
+
path."""
|
|
312
|
+
py_files = _find_py_files(repo)
|
|
313
|
+
idx: Index = build_index(repo, py_files)
|
|
314
|
+
collected = _collect_edges(repo, py_files, idx.module_candidates, idx.suffix_index)
|
|
315
|
+
|
|
316
|
+
for importer_rel, cands in collected.warnings:
|
|
317
|
+
cand_strs = ", ".join(str(c.relative_to(repo)) for c in cands)
|
|
318
|
+
print(
|
|
319
|
+
f"warning: ambiguous import in {importer_rel}: candidates {cand_strs}",
|
|
320
|
+
file=sys.stderr,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
nodes = frozenset(f.relative_to(repo) for f in py_files)
|
|
324
|
+
file_graph = _build_file_graph(nodes, collected.edge_names)
|
|
325
|
+
|
|
326
|
+
return _RepoGraph(
|
|
327
|
+
py_files,
|
|
328
|
+
idx.dirs_with_py,
|
|
329
|
+
collected.edge_names,
|
|
330
|
+
collected.defined_names,
|
|
331
|
+
collected.registered_names,
|
|
332
|
+
file_graph.adj,
|
|
333
|
+
file_graph.radj,
|
|
334
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Name/module-path helpers shared by `_check` (privacy enforcement),
|
|
2
|
+
`_summary` (rendering) and `_resolve`/`_graph` (import resolution)."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def module_comp(parts: tuple[str, ...]) -> tuple[str, ...]:
|
|
6
|
+
"""Dotted components for a module path. `parts` is usually a .py file's
|
|
7
|
+
relative path, but a directory (namespace package, or a target equal to
|
|
8
|
+
its own package root) is also valid: empty parts stay empty, and a
|
|
9
|
+
directory component (no `.py` suffix) is kept as-is rather than having
|
|
10
|
+
3 chars stripped off."""
|
|
11
|
+
if not parts:
|
|
12
|
+
return ()
|
|
13
|
+
if parts[-1] == "__init__.py":
|
|
14
|
+
return parts[:-1]
|
|
15
|
+
if parts[-1].endswith(".py"):
|
|
16
|
+
return parts[:-1] + (parts[-1][:-3],)
|
|
17
|
+
return parts
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_private_name(name: str) -> bool:
|
|
21
|
+
"""A name private by Rust's convention, ported to PEP 8 underscores:
|
|
22
|
+
one leading underscore, not a dunder, not the bare `_` placeholder
|
|
23
|
+
name."""
|
|
24
|
+
return name.startswith("_") and not name.startswith("__") and name != "_"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Package-root discovery: directories whose pyproject.toml has a
|
|
2
|
+
[project] table, used by `_check` (the privacy rule's package-root anchor)
|
|
3
|
+
and `_deps` (the dependency-declaration rule's per-package scan unit)."""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tomllib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from pymodularity._parse.graph import should_skip_dir
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _has_project_table(pyproject: Path) -> bool:
|
|
13
|
+
try:
|
|
14
|
+
with pyproject.open("rb") as f:
|
|
15
|
+
data = tomllib.load(f)
|
|
16
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
17
|
+
return False
|
|
18
|
+
return "project" in data
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def find_package_roots(repo: Path) -> tuple[Path, ...]:
|
|
22
|
+
"""Directories (relative to repo) whose pyproject.toml has a [project]
|
|
23
|
+
table, used only to anchor the privacy rule's "package root". Sorted
|
|
24
|
+
deepest-first so the closest ancestor is found first."""
|
|
25
|
+
roots: list[Path] = []
|
|
26
|
+
for dirpath, dirnames, filenames in os.walk(repo):
|
|
27
|
+
dirnames[:] = [d for d in dirnames if not should_skip_dir(d)]
|
|
28
|
+
if "pyproject.toml" in filenames and _has_project_table(
|
|
29
|
+
Path(dirpath) / "pyproject.toml"
|
|
30
|
+
):
|
|
31
|
+
roots.append(Path(dirpath).relative_to(repo))
|
|
32
|
+
return tuple(sorted(roots, key=lambda p: len(p.parts), reverse=True))
|
pymodularity/_scores.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Reach (transitive dependents/dependencies) and the file/directory
|
|
2
|
+
scores derived from it: `compute_scores`, the shared first step `graph`,
|
|
3
|
+
`metrics` and the view summary all build on."""
|
|
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
|
+
|
|
11
|
+
def _reachable_set(start: Path, adj: Mapping[Path, AbstractSet[Path]]) -> frozenset[Path]:
|
|
12
|
+
"""Every other node `start` can reach, following `adj` (BFS/DFS, order
|
|
13
|
+
doesn't matter); `start` itself never counts even inside a cycle back
|
|
14
|
+
to itself."""
|
|
15
|
+
seen: set[Path] = set()
|
|
16
|
+
stack = [start]
|
|
17
|
+
while stack:
|
|
18
|
+
node = stack.pop()
|
|
19
|
+
for nxt in adj.get(node, ()):
|
|
20
|
+
if nxt != start and nxt not in seen:
|
|
21
|
+
seen.add(nxt)
|
|
22
|
+
stack.append(nxt)
|
|
23
|
+
return frozenset(seen)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class _Reach:
|
|
28
|
+
"""Per file: the set of dependents (other files with an import path to
|
|
29
|
+
it) and the set of dependencies (other files it has an import path
|
|
30
|
+
to), both transitive."""
|
|
31
|
+
|
|
32
|
+
dependents: Mapping[Path, frozenset[Path]]
|
|
33
|
+
dependencies: Mapping[Path, frozenset[Path]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _compute_reach(
|
|
37
|
+
nodes: AbstractSet[Path],
|
|
38
|
+
adj: Mapping[Path, AbstractSet[Path]],
|
|
39
|
+
radj: Mapping[Path, AbstractSet[Path]],
|
|
40
|
+
) -> _Reach:
|
|
41
|
+
dependents = {n: _reachable_set(n, radj) for n in nodes}
|
|
42
|
+
dependencies = {n: _reachable_set(n, adj) for n in nodes}
|
|
43
|
+
return _Reach(dependents, dependencies)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class FileScore:
|
|
48
|
+
"""A file's score: dependents x dependencies, both transitive."""
|
|
49
|
+
|
|
50
|
+
path: Path
|
|
51
|
+
dependents: int
|
|
52
|
+
dependencies: int
|
|
53
|
+
score: int
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class DirScore:
|
|
58
|
+
"""A directory's score, measured across its own boundary, repo-wide:
|
|
59
|
+
`reach_in` is how many files outside the directory reach some file
|
|
60
|
+
inside it (transitively); `reach_out` is how many files outside it
|
|
61
|
+
are reached by some file inside it (transitively); score = in x out."""
|
|
62
|
+
|
|
63
|
+
path: Path
|
|
64
|
+
reach_in: int
|
|
65
|
+
reach_out: int
|
|
66
|
+
score: int
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _compute_file_scores(
|
|
70
|
+
nodes: AbstractSet[Path],
|
|
71
|
+
dependents: Mapping[Path, AbstractSet[Path]],
|
|
72
|
+
dependencies: Mapping[Path, AbstractSet[Path]],
|
|
73
|
+
) -> Mapping[Path, FileScore]:
|
|
74
|
+
scores: dict[Path, FileScore] = {}
|
|
75
|
+
for n in nodes:
|
|
76
|
+
d_in = len(dependents[n])
|
|
77
|
+
d_out = len(dependencies[n])
|
|
78
|
+
scores[n] = FileScore(n, d_in, d_out, d_in * d_out)
|
|
79
|
+
return scores
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class _Scores:
|
|
84
|
+
"""`graph` and `metrics`'s shared first step's result: file and
|
|
85
|
+
directory scores, computed from reach."""
|
|
86
|
+
|
|
87
|
+
file_scores: Mapping[Path, FileScore]
|
|
88
|
+
dir_scores: Mapping[Path, DirScore]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def compute_scores(
|
|
92
|
+
dirs_with_py: AbstractSet[Path],
|
|
93
|
+
adj: Mapping[Path, AbstractSet[Path]],
|
|
94
|
+
radj: Mapping[Path, AbstractSet[Path]],
|
|
95
|
+
) -> _Scores:
|
|
96
|
+
"""`graph` and `metrics`'s shared first step: reach, then file and
|
|
97
|
+
directory scores from it."""
|
|
98
|
+
nodes = set(adj)
|
|
99
|
+
reach = _compute_reach(nodes, adj, radj)
|
|
100
|
+
file_scores = _compute_file_scores(nodes, reach.dependents, reach.dependencies)
|
|
101
|
+
dir_scores = _compute_dir_scores(dirs_with_py, reach.dependents, reach.dependencies)
|
|
102
|
+
return _Scores(file_scores, dir_scores)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def ancestor_dirs(rel: Path) -> tuple[Path, ...]:
|
|
106
|
+
"""The directory holding `rel` and every ancestor directory up to the
|
|
107
|
+
repo root ('.'), inclusive."""
|
|
108
|
+
out = [rel.parent]
|
|
109
|
+
while out[-1] != Path("."):
|
|
110
|
+
out.append(out[-1].parent)
|
|
111
|
+
return tuple(out)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _compute_dir_scores(
|
|
115
|
+
dirs_with_py: AbstractSet[Path],
|
|
116
|
+
dependents: Mapping[Path, AbstractSet[Path]],
|
|
117
|
+
dependencies: Mapping[Path, AbstractSet[Path]],
|
|
118
|
+
) -> Mapping[Path, DirScore]:
|
|
119
|
+
dir_in: dict[Path, set[Path]] = {}
|
|
120
|
+
dir_out: dict[Path, set[Path]] = {}
|
|
121
|
+
for f, dep_set in dependents.items():
|
|
122
|
+
for d in ancestor_dirs(f):
|
|
123
|
+
if d in dirs_with_py:
|
|
124
|
+
dir_in.setdefault(d, set()).update(dep_set)
|
|
125
|
+
for f, dep_set in dependencies.items():
|
|
126
|
+
for d in ancestor_dirs(f):
|
|
127
|
+
if d in dirs_with_py:
|
|
128
|
+
dir_out.setdefault(d, set()).update(dep_set)
|
|
129
|
+
|
|
130
|
+
scores: dict[Path, DirScore] = {}
|
|
131
|
+
for d in dirs_with_py:
|
|
132
|
+
in_count = sum(1 for g in dir_in.get(d, set()) if not g.is_relative_to(d))
|
|
133
|
+
out_count = sum(1 for g in dir_out.get(d, set()) if not g.is_relative_to(d))
|
|
134
|
+
scores[d] = DirScore(d, in_count, out_count, in_count * out_count)
|
|
135
|
+
return scores
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""The structure-limit rules (file size, directory fan-out, package size,
|
|
2
|
+
public top-level-name count per file) `pymodularity check` enforces. All
|
|
3
|
+
limits are fixed constants, no config, no baseline. Imported only by
|
|
4
|
+
`cli`."""
|
|
5
|
+
|
|
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.packages import find_package_roots
|
|
12
|
+
|
|
13
|
+
_MAX_FILE_LINES = 500
|
|
14
|
+
_MAX_DIR_ENTRIES = 10
|
|
15
|
+
_MAX_PACKAGE_FILES = 100
|
|
16
|
+
_MAX_PUBLIC_NAMES = 15
|
|
17
|
+
|
|
18
|
+
# __init__.py and __main__.py don't count as directory entries (rule 2);
|
|
19
|
+
# every other scanned file counts fully for every rule.
|
|
20
|
+
_STRUCTURAL_FILES = frozenset({"__init__.py", "__main__.py"})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _is_public(name: str) -> bool:
|
|
24
|
+
return not name.startswith("_")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class _FileTooLong:
|
|
29
|
+
file: Path
|
|
30
|
+
lines: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class _DirTooManyEntries:
|
|
35
|
+
directory: Path
|
|
36
|
+
entries: int
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class _PackageTooManyFiles:
|
|
41
|
+
package: Path
|
|
42
|
+
files: int
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class _FileTooWide:
|
|
47
|
+
file: Path
|
|
48
|
+
names: int
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class _StructureFindings:
|
|
53
|
+
too_long: tuple[_FileTooLong, ...] = ()
|
|
54
|
+
too_many_entries: tuple[_DirTooManyEntries, ...] = ()
|
|
55
|
+
package_too_big: tuple[_PackageTooManyFiles, ...] = ()
|
|
56
|
+
too_wide: tuple[_FileTooWide, ...] = ()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _is_direct_child(child: Path, parent: Path) -> bool:
|
|
60
|
+
if child == parent:
|
|
61
|
+
return False
|
|
62
|
+
if parent == Path("."):
|
|
63
|
+
return len(child.parts) == 1
|
|
64
|
+
return child.parent == parent
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _find_file_size_violations(
|
|
68
|
+
repo: Path, py_files: Sequence[Path]
|
|
69
|
+
) -> tuple[_FileTooLong, ...]:
|
|
70
|
+
out: list[_FileTooLong] = []
|
|
71
|
+
for f in py_files:
|
|
72
|
+
n = len(f.read_text(encoding="utf-8").splitlines())
|
|
73
|
+
if n > _MAX_FILE_LINES:
|
|
74
|
+
out.append(_FileTooLong(f.relative_to(repo), n))
|
|
75
|
+
out.sort(key=lambda v: str(v.file))
|
|
76
|
+
return tuple(out)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _find_dir_entry_violations(
|
|
80
|
+
dirs_with_py: AbstractSet[Path], py_files: Sequence[Path], repo: Path
|
|
81
|
+
) -> tuple[_DirTooManyEntries, ...]:
|
|
82
|
+
rels = [f.relative_to(repo) for f in py_files]
|
|
83
|
+
out: list[_DirTooManyEntries] = []
|
|
84
|
+
for d in dirs_with_py:
|
|
85
|
+
file_entries = sum(
|
|
86
|
+
1 for r in rels if r.parent == d and r.name not in _STRUCTURAL_FILES
|
|
87
|
+
)
|
|
88
|
+
dir_entries = sum(1 for c in dirs_with_py if _is_direct_child(c, d))
|
|
89
|
+
total = file_entries + dir_entries
|
|
90
|
+
if total > _MAX_DIR_ENTRIES:
|
|
91
|
+
out.append(_DirTooManyEntries(d, total))
|
|
92
|
+
out.sort(key=lambda v: str(v.directory))
|
|
93
|
+
return tuple(out)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _owning_package(rel: Path, package_roots: Sequence[Path]) -> Path:
|
|
97
|
+
for root in package_roots:
|
|
98
|
+
if root != Path(".") and rel.is_relative_to(root):
|
|
99
|
+
return root
|
|
100
|
+
return Path(".")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _find_package_size_violations(
|
|
104
|
+
repo: Path, py_files: Sequence[Path]
|
|
105
|
+
) -> tuple[_PackageTooManyFiles, ...]:
|
|
106
|
+
package_roots = find_package_roots(repo)
|
|
107
|
+
counts: dict[Path, int] = {}
|
|
108
|
+
for f in py_files:
|
|
109
|
+
root = _owning_package(f.relative_to(repo), package_roots)
|
|
110
|
+
counts[root] = counts.get(root, 0) + 1
|
|
111
|
+
out = [
|
|
112
|
+
_PackageTooManyFiles(pkg, n)
|
|
113
|
+
for pkg, n in counts.items()
|
|
114
|
+
if n > _MAX_PACKAGE_FILES
|
|
115
|
+
]
|
|
116
|
+
out.sort(key=lambda v: str(v.package))
|
|
117
|
+
return tuple(out)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _find_file_width_violations(
|
|
121
|
+
defined_names: Mapping[Path, frozenset[str]],
|
|
122
|
+
registered_names: Mapping[Path, frozenset[str]],
|
|
123
|
+
) -> tuple[_FileTooWide, ...]:
|
|
124
|
+
out: list[_FileTooWide] = []
|
|
125
|
+
for f, names in defined_names.items():
|
|
126
|
+
registered = registered_names.get(f, frozenset())
|
|
127
|
+
public_count = sum(1 for n in names if _is_public(n) and n not in registered)
|
|
128
|
+
if public_count > _MAX_PUBLIC_NAMES:
|
|
129
|
+
out.append(_FileTooWide(f, public_count))
|
|
130
|
+
out.sort(key=lambda v: str(v.file))
|
|
131
|
+
return tuple(out)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def find_structure_findings(
|
|
135
|
+
repo: Path,
|
|
136
|
+
py_files: Sequence[Path],
|
|
137
|
+
dirs_with_py: AbstractSet[Path],
|
|
138
|
+
defined_names: Mapping[Path, frozenset[str]],
|
|
139
|
+
registered_names: Mapping[Path, frozenset[str]],
|
|
140
|
+
) -> _StructureFindings:
|
|
141
|
+
return _StructureFindings(
|
|
142
|
+
too_long=_find_file_size_violations(repo, py_files),
|
|
143
|
+
too_many_entries=_find_dir_entry_violations(dirs_with_py, py_files, repo),
|
|
144
|
+
package_too_big=_find_package_size_violations(repo, py_files),
|
|
145
|
+
too_wide=_find_file_width_violations(defined_names, registered_names),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def print_structure_findings(findings: _StructureFindings) -> bool:
|
|
150
|
+
total = (
|
|
151
|
+
len(findings.too_long)
|
|
152
|
+
+ len(findings.too_many_entries)
|
|
153
|
+
+ len(findings.package_too_big)
|
|
154
|
+
+ len(findings.too_wide)
|
|
155
|
+
)
|
|
156
|
+
print(f"## Structure limits: {total} finding(s)")
|
|
157
|
+
for v in findings.too_long:
|
|
158
|
+
print(f"- {v.file}: {v.lines} lines (limit {_MAX_FILE_LINES})")
|
|
159
|
+
for v in findings.too_many_entries:
|
|
160
|
+
label = "repo root" if v.directory == Path(".") else str(v.directory)
|
|
161
|
+
print(f"- {label}: {v.entries} entries (limit {_MAX_DIR_ENTRIES})")
|
|
162
|
+
for v in findings.package_too_big:
|
|
163
|
+
label = "repo root" if v.package == Path(".") else str(v.package)
|
|
164
|
+
print(f"- {label}: {v.files} files (limit {_MAX_PACKAGE_FILES})")
|
|
165
|
+
for v in findings.too_wide:
|
|
166
|
+
print(f"- {v.file}: {v.names} public names (limit {_MAX_PUBLIC_NAMES})")
|
|
167
|
+
print()
|
|
168
|
+
return total > 0
|