pymodularity 0.2.0__tar.gz

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,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: pymodularity
3
+ Version: 0.2.0
4
+ Summary: See and check a repo's real Python imports: a privacy convention on PEP 8 underscores, an import-cycle ban, and a coupling report, all derived from the code.
5
+ Author: Tilo Himmelsbach
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
8
+ Project-URL: Homepage, https://github.com/dertilo/pymodularity
9
+ Project-URL: Repository, https://github.com/dertilo/pymodularity
10
+ Project-URL: Issues, https://github.com/dertilo/pymodularity/issues
11
+ Description-Content-Type: text/markdown
12
+
13
+ # pymodularity
14
+
15
+ Formerly published as `underscope`.
16
+
17
+ The underscore's position in a Python import path sets that import's scope — Rust's `pub(super)`/`pub(crate)`, applied to PEP 8 underscores.
18
+
19
+ `pymodularity` draws a repo's real Python imports as mermaid diagrams, checks them against conventions derived from the code itself (no allow-list to maintain), and reports which files and directories carry the most structural coupling. It needs only the standard library to analyze your code — no runtime dependencies. `check`'s dependency-declaration rule additionally needs `uv` on PATH (see below).
20
+
21
+ ## Install and run
22
+
23
+ ```bash
24
+ uvx pymodularity check .
25
+ uvx pymodularity graph . --out docs/imports
26
+ uvx pymodularity metrics .
27
+ ```
28
+
29
+ Or, inside a project: `uv add --dev pymodularity` and run `pymodularity ...` in that project's venv.
30
+
31
+ ## The rules `check` enforces
32
+
33
+ Given this tree:
34
+
35
+ ```
36
+ shop/
37
+ __init__.py
38
+ billing/
39
+ __init__.py
40
+ invoice.py # from shop.billing._pricing import compute_total
41
+ _pricing.py # def compute_total(): ...
42
+ catalog/
43
+ __init__.py
44
+ listing.py # from shop.billing._pricing import compute_total <- finding
45
+ ```
46
+
47
+ - **No import cycle**, at any directory level: between files, and between directories at every depth. Imports under `if TYPE_CHECKING:` and inside function bodies count. There is no baseline — an existing cycle fails too.
48
+ - **A private item stays visible only to its owner and the owner's descendants** (Go's `internal/`, applied as Rust's visibility rule to PEP 8 underscores):
49
+ - a private module — a file, or a path component, named `_x` (single leading underscore, not a dunder) — belongs to the directory holding it; only files under that directory may import it, across package boundaries;
50
+ - a private name imported via `from m import _x` belongs to `m`; if `m` is a single file, no other file may import `_x`, not even a sibling in the same directory;
51
+ - if `m` is a package (an `__init__.py` or a namespace package directory), `_x` belongs to that directory, and files under it may import it.
52
+ - a parent never reaches into a child's private items, and neither do siblings.
53
+ - `shop/billing/invoice.py` above is allowed (it's under `shop/billing`, `_pricing`'s owner); `shop/catalog/listing.py` is a finding.
54
+ - the attribute form (`import shop.billing._pricing as p; p.compute_total`) is out of scope here — ruff's `SLF001` already flags it.
55
+
56
+ `pymodularity check .` exits 1 on any finding and prints, per finding, the importing file, the target, and the directory or file that owns it.
57
+
58
+ - **Dependency declarations are correct**, on by default, skipped with `check --no-deps`: for each package root (a directory with a `pyproject.toml` `[project]` table), runs [deptry](https://github.com/fpgmaas/deptry) (`uv run --directory <pkg> --with deptry==<pinned version> --frozen deptry . --json-output <tmp>`, a nested package root excluded from its parent's scan via `--extend-exclude`) and reports its findings (unused, missing, and transitive dependencies) the same way as the other rules. A package is skipped, not scanned, if it declares no runtime dependencies, or has no `uv.lock` of its own. This rule needs `uv` on PATH — `check` exits 2 with an error if it is missing, never a silent skip. The first run creates a `.venv` inside each scanned package and downloads its dependencies; later runs reuse it.
59
+
60
+ - **Structure limits**, fixed constants, no config, no baseline — every one of these is a finding regardless of how long it's been true:
61
+ - a scanned file has at most 500 physical lines.
62
+ - a directory has at most 10 entries, where an entry is a scanned `.py` file directly in it or a subdirectory holding scanned `.py` files, transitively; `__init__.py` and `__main__.py` don't count as entries (they're still scanned and still count toward the other limits).
63
+ - a package root (the same `pyproject.toml` `[project]`-table directory the privacy and dependency rules anchor on) has at most 100 scanned files, each file counted against its nearest (innermost) package root.
64
+ - a file has at most 15 public top-level names (no leading `_`): `def`/`async def`/`class`, a plain or annotated assignment target (including tuple-unpacking), or a `type X = ...` alias, at top level or nested in a top-level `if`/`try` block (including `elif`/`else`, `except` handlers, `finally`). Imports never count. A top-level function (not a class — `@dataclass` sits on most models) a decorator registers doesn't count either: FastAPI, Typer/Click, Dagster, pytest and the like derive part of their contract (route `operationId`, CLI command name, asset key, fixture name) from the function's name, so counting it toward the width cap would pressure someone into renaming or restructuring around a name the framework, not this tool, owns. Any decorator counts — there's no list of framework names.
65
+
66
+ ## The 3 subcommands
67
+
68
+ - `pymodularity check <repo> [--no-deps]` — run all four rules above, print findings, exit 1 on any finding (`--no-deps` skips the dependency-declaration rule).
69
+ - `pymodularity graph <repo> --out DIR [--html]` — write one mermaid view per directory under `DIR`: `index.md` everywhere, plus a clickable `index.html` per view with `--html` (needs network access in the browser to load mermaid from a CDN).
70
+ - `pymodularity metrics <repo>` — print a coupling report to stdout: reach (dependents/dependencies, transitive) and a `score` per file and per directory; interface width (names other code imports from a unit); exposure share (how much of what a file defines actually gets used elsewhere); entry files (how much of a directory outside code actually reaches into); misplaced files (files whose closest partners sit mostly in another directory). Always exits 0 — a report, never a gate.
71
+
72
+ ## Blind spots
73
+
74
+ `pymodularity` shows import coupling only, a lower bound on logical coupling ("if A changes, must B?"). Coupling that bypasses Python imports stays invisible:
75
+
76
+ - HTTP APIs, shared database schemas and tables, config keys and env vars, message and file formats, string-keyed registries, runtime `sys.path` edits.
77
+ - Two files with no edge here can still force each other's edits. **Never read a missing edge as independence.**
78
+
79
+ Other limits:
80
+
81
+ - a `warning: ambiguous import` line (printed to stderr) means several files in the repo match one import; no edge is drawn for it.
82
+ - an import that only resolves through a runtime `sys.path` edit is matched by file path, which can pick the wrong file.
83
+ - a third-party name that happens to equal a package folder elsewhere in the repo draws a false edge.
84
+ - test directories (`tests/`, `test/`) and test files (`test_*.py`, `*_test.py`, `conftest.py`) are always skipped, no flag — a test importing a sibling would otherwise draw a production dependency that production never has.
85
+ - `metrics` is a report on numbers that keep moving even on a repo that passes `check` clean; gating on it rewards gaming the number (for example, replacing an import with a string-keyed registry) instead of actually lowering coupling.
@@ -0,0 +1,73 @@
1
+ # pymodularity
2
+
3
+ Formerly published as `underscope`.
4
+
5
+ The underscore's position in a Python import path sets that import's scope — Rust's `pub(super)`/`pub(crate)`, applied to PEP 8 underscores.
6
+
7
+ `pymodularity` draws a repo's real Python imports as mermaid diagrams, checks them against conventions derived from the code itself (no allow-list to maintain), and reports which files and directories carry the most structural coupling. It needs only the standard library to analyze your code — no runtime dependencies. `check`'s dependency-declaration rule additionally needs `uv` on PATH (see below).
8
+
9
+ ## Install and run
10
+
11
+ ```bash
12
+ uvx pymodularity check .
13
+ uvx pymodularity graph . --out docs/imports
14
+ uvx pymodularity metrics .
15
+ ```
16
+
17
+ Or, inside a project: `uv add --dev pymodularity` and run `pymodularity ...` in that project's venv.
18
+
19
+ ## The rules `check` enforces
20
+
21
+ Given this tree:
22
+
23
+ ```
24
+ shop/
25
+ __init__.py
26
+ billing/
27
+ __init__.py
28
+ invoice.py # from shop.billing._pricing import compute_total
29
+ _pricing.py # def compute_total(): ...
30
+ catalog/
31
+ __init__.py
32
+ listing.py # from shop.billing._pricing import compute_total <- finding
33
+ ```
34
+
35
+ - **No import cycle**, at any directory level: between files, and between directories at every depth. Imports under `if TYPE_CHECKING:` and inside function bodies count. There is no baseline — an existing cycle fails too.
36
+ - **A private item stays visible only to its owner and the owner's descendants** (Go's `internal/`, applied as Rust's visibility rule to PEP 8 underscores):
37
+ - a private module — a file, or a path component, named `_x` (single leading underscore, not a dunder) — belongs to the directory holding it; only files under that directory may import it, across package boundaries;
38
+ - a private name imported via `from m import _x` belongs to `m`; if `m` is a single file, no other file may import `_x`, not even a sibling in the same directory;
39
+ - if `m` is a package (an `__init__.py` or a namespace package directory), `_x` belongs to that directory, and files under it may import it.
40
+ - a parent never reaches into a child's private items, and neither do siblings.
41
+ - `shop/billing/invoice.py` above is allowed (it's under `shop/billing`, `_pricing`'s owner); `shop/catalog/listing.py` is a finding.
42
+ - the attribute form (`import shop.billing._pricing as p; p.compute_total`) is out of scope here — ruff's `SLF001` already flags it.
43
+
44
+ `pymodularity check .` exits 1 on any finding and prints, per finding, the importing file, the target, and the directory or file that owns it.
45
+
46
+ - **Dependency declarations are correct**, on by default, skipped with `check --no-deps`: for each package root (a directory with a `pyproject.toml` `[project]` table), runs [deptry](https://github.com/fpgmaas/deptry) (`uv run --directory <pkg> --with deptry==<pinned version> --frozen deptry . --json-output <tmp>`, a nested package root excluded from its parent's scan via `--extend-exclude`) and reports its findings (unused, missing, and transitive dependencies) the same way as the other rules. A package is skipped, not scanned, if it declares no runtime dependencies, or has no `uv.lock` of its own. This rule needs `uv` on PATH — `check` exits 2 with an error if it is missing, never a silent skip. The first run creates a `.venv` inside each scanned package and downloads its dependencies; later runs reuse it.
47
+
48
+ - **Structure limits**, fixed constants, no config, no baseline — every one of these is a finding regardless of how long it's been true:
49
+ - a scanned file has at most 500 physical lines.
50
+ - a directory has at most 10 entries, where an entry is a scanned `.py` file directly in it or a subdirectory holding scanned `.py` files, transitively; `__init__.py` and `__main__.py` don't count as entries (they're still scanned and still count toward the other limits).
51
+ - a package root (the same `pyproject.toml` `[project]`-table directory the privacy and dependency rules anchor on) has at most 100 scanned files, each file counted against its nearest (innermost) package root.
52
+ - a file has at most 15 public top-level names (no leading `_`): `def`/`async def`/`class`, a plain or annotated assignment target (including tuple-unpacking), or a `type X = ...` alias, at top level or nested in a top-level `if`/`try` block (including `elif`/`else`, `except` handlers, `finally`). Imports never count. A top-level function (not a class — `@dataclass` sits on most models) a decorator registers doesn't count either: FastAPI, Typer/Click, Dagster, pytest and the like derive part of their contract (route `operationId`, CLI command name, asset key, fixture name) from the function's name, so counting it toward the width cap would pressure someone into renaming or restructuring around a name the framework, not this tool, owns. Any decorator counts — there's no list of framework names.
53
+
54
+ ## The 3 subcommands
55
+
56
+ - `pymodularity check <repo> [--no-deps]` — run all four rules above, print findings, exit 1 on any finding (`--no-deps` skips the dependency-declaration rule).
57
+ - `pymodularity graph <repo> --out DIR [--html]` — write one mermaid view per directory under `DIR`: `index.md` everywhere, plus a clickable `index.html` per view with `--html` (needs network access in the browser to load mermaid from a CDN).
58
+ - `pymodularity metrics <repo>` — print a coupling report to stdout: reach (dependents/dependencies, transitive) and a `score` per file and per directory; interface width (names other code imports from a unit); exposure share (how much of what a file defines actually gets used elsewhere); entry files (how much of a directory outside code actually reaches into); misplaced files (files whose closest partners sit mostly in another directory). Always exits 0 — a report, never a gate.
59
+
60
+ ## Blind spots
61
+
62
+ `pymodularity` shows import coupling only, a lower bound on logical coupling ("if A changes, must B?"). Coupling that bypasses Python imports stays invisible:
63
+
64
+ - HTTP APIs, shared database schemas and tables, config keys and env vars, message and file formats, string-keyed registries, runtime `sys.path` edits.
65
+ - Two files with no edge here can still force each other's edits. **Never read a missing edge as independence.**
66
+
67
+ Other limits:
68
+
69
+ - a `warning: ambiguous import` line (printed to stderr) means several files in the repo match one import; no edge is drawn for it.
70
+ - an import that only resolves through a runtime `sys.path` edit is matched by file path, which can pick the wrong file.
71
+ - a third-party name that happens to equal a package folder elsewhere in the repo draws a false edge.
72
+ - test directories (`tests/`, `test/`) and test files (`test_*.py`, `*_test.py`, `conftest.py`) are always skipped, no flag — a test importing a sibling would otherwise draw a production dependency that production never has.
73
+ - `metrics` is a report on numbers that keep moving even on a repo that passes `check` clean; gating on it rewards gaming the number (for example, replacing an import with a string-keyed registry) instead of actually lowering coupling.
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "pymodularity"
3
+ version = "0.2.0"
4
+ description = "See and check a repo's real Python imports: a privacy convention on PEP 8 underscores, an import-cycle ban, and a coupling report, all derived from the code."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.11"
8
+ dependencies = []
9
+
10
+ [[project.authors]]
11
+ name = "Tilo Himmelsbach"
12
+
13
+ [project.urls]
14
+ Homepage = "https://github.com/dertilo/pymodularity"
15
+ Repository = "https://github.com/dertilo/pymodularity"
16
+ Issues = "https://github.com/dertilo/pymodularity/issues"
17
+
18
+ [project.scripts]
19
+ pymodularity = "pymodularity.cli:main"
20
+
21
+ [dependency-groups]
22
+ dev = [
23
+ "pytest",
24
+ "ruff",
25
+ "basedpyright",
26
+ ]
27
+
28
+ [build-system]
29
+ requires = ["uv_build>=0.10.7,<0.11.0"]
30
+ build-backend = "uv_build"
31
+
32
+ [tool.ruff]
33
+ line-length = 90
34
+ target-version = "py311"
35
+
36
+ [tool.basedpyright]
37
+ include = [
38
+ "src",
39
+ "tests",
40
+ ]
41
+ pythonVersion = "3.11"
42
+ reportUnusedCallResult = false
43
+ reportAny = false
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "pymodularity"
3
+ version = "0.2.0"
4
+ description = "See and check a repo's real Python imports: a privacy convention on PEP 8 underscores, an import-cycle ban, and a coupling report, all derived from the code."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.11"
8
+ authors = [{ name = "Tilo Himmelsbach" }]
9
+ dependencies = []
10
+
11
+ [project.urls]
12
+ Homepage = "https://github.com/dertilo/pymodularity"
13
+ Repository = "https://github.com/dertilo/pymodularity"
14
+ Issues = "https://github.com/dertilo/pymodularity/issues"
15
+
16
+ [project.scripts]
17
+ pymodularity = "pymodularity.cli:main"
18
+
19
+ [dependency-groups]
20
+ dev = ["pytest", "ruff", "basedpyright"]
21
+
22
+ [build-system]
23
+ requires = ["uv_build>=0.10.7,<0.11.0"]
24
+ build-backend = "uv_build"
25
+
26
+ [tool.ruff]
27
+ line-length = 90
28
+ target-version = "py311"
29
+
30
+ [tool.basedpyright]
31
+ include = ["src", "tests"]
32
+ pythonVersion = "3.11"
33
+ reportUnusedCallResult = false
34
+ reportAny = false
@@ -0,0 +1,5 @@
1
+ """pymodularity: see and check a repo's real Python imports."""
2
+
3
+ from pymodularity.cli import __version__
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,7 @@
1
+ """Entry point for `python -m pymodularity`."""
2
+
3
+ from pymodularity.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ # nosemgrep: no-raise-outside-boundary # boundary: CLI entry point, the process exit boundary
7
+ raise SystemExit(main())
@@ -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
@@ -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)