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.
@@ -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))
@@ -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