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,71 @@
1
+ """Directory "view" edges: aggregating the file-level import graph into
2
+ one directory's view (its inside children plus the outside nodes they
3
+ touch), and the inside/outside node split every view-level consumer
4
+ shares."""
5
+
6
+ from collections.abc import Mapping
7
+ from pathlib import Path
8
+
9
+ from pymodularity._parse.graph import EdgeKey, EdgeNames
10
+
11
+ # (view node src, view node tgt)
12
+ PairKey = tuple[Path, Path]
13
+ PairEdges = Mapping[PairKey, frozenset[EdgeKey]]
14
+
15
+
16
+ def _lca(a: Path, b: Path) -> Path:
17
+ pa = () if a == Path(".") else a.parts
18
+ pb = () if b == Path(".") else b.parts
19
+ common: list[str] = []
20
+ for x, y in zip(pa, pb):
21
+ if x != y:
22
+ break
23
+ common.append(x)
24
+ return Path(*common) if common else Path(".")
25
+
26
+
27
+ def _child_of(directory: Path, path: Path) -> Path:
28
+ if directory == Path("."):
29
+ return Path(path.parts[0])
30
+ rel = path.relative_to(directory)
31
+ return directory / rel.parts[0]
32
+
33
+
34
+ def _outside_node(directory: Path, path: Path) -> Path:
35
+ return _child_of(_lca(directory, path), path)
36
+
37
+
38
+ def build_view(directory: Path, edge_names: EdgeNames) -> PairEdges:
39
+ # (node_src, node_tgt) -> set of (importer_rel, target_rel) file-level edges
40
+ pair_edges: dict[PairKey, set[EdgeKey]] = {}
41
+ for f_rel, t_rel in edge_names:
42
+ f_in = f_rel.is_relative_to(directory)
43
+ t_in = t_rel.is_relative_to(directory)
44
+ if not f_in and not t_in:
45
+ continue
46
+ if directory.is_relative_to(f_rel) or directory.is_relative_to(t_rel):
47
+ # a namespace-package dir endpoint equals the view directory or
48
+ # is an ancestor of it: there is no distinct child/outside node.
49
+ continue
50
+ if f_in and t_in:
51
+ src = _child_of(directory, f_rel)
52
+ tgt = _child_of(directory, t_rel)
53
+ elif f_in:
54
+ src = _child_of(directory, f_rel)
55
+ tgt = _outside_node(directory, t_rel)
56
+ else:
57
+ src = _outside_node(directory, f_rel)
58
+ tgt = _child_of(directory, t_rel)
59
+ if src == tgt:
60
+ continue
61
+ pair_edges.setdefault((src, tgt), set()).add((f_rel, t_rel))
62
+ return {key: frozenset(edges) for key, edges in pair_edges.items()}
63
+
64
+
65
+ def compute_inside_nodes(directory: Path, pair_edges: PairEdges) -> frozenset[Path]:
66
+ nodes: set[Path] = set()
67
+ for src, tgt in pair_edges:
68
+ nodes.add(src)
69
+ nodes.add(tgt)
70
+ outside = {n for n in nodes if not n.is_relative_to(directory)}
71
+ return frozenset(nodes - outside)
File without changes
@@ -0,0 +1,252 @@
1
+ """A directory view's node summary table: one row per inside child, with
2
+ its score columns (via `_scores`) and the Cycles section (via `_cycles`),
3
+ rendered as the markdown block `render.write_view` embeds."""
4
+
5
+ from collections.abc import Callable, Mapping
6
+ from collections.abc import Set as AbstractSet
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from pymodularity._cycles import compute_sccs
11
+ from pymodularity._parse.graph import EdgeKey, EdgeNames
12
+ from pymodularity._parse.names import is_private_name
13
+ from pymodularity._scores import DirScore, FileScore
14
+ from pymodularity._view_edges import PairEdges
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class _NamedCount:
19
+ """One imported name with the count of units importing it."""
20
+
21
+ name: str
22
+ count: int
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class _InitSplit:
27
+ """A node with an `__init__.py`: how its incoming edges split between
28
+ the init file itself and the other files inside it."""
29
+
30
+ root: int
31
+ internals: int
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class _NoInitFile:
36
+ """A node with no `__init__.py` to split incoming edges against."""
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class _NodeRow:
41
+ """One inside child's row in a view's summary node table."""
42
+
43
+ node: Path
44
+ importing_units: int
45
+ distinct_names: int
46
+ top_names: tuple[_NamedCount, ...]
47
+ internal: int
48
+ outgoing: int
49
+ init_split: _InitSplit | _NoInitFile
50
+ private: int
51
+ reach_a: int
52
+ reach_b: int
53
+ score: int
54
+
55
+
56
+ def _compute_node_rows(
57
+ repo: Path,
58
+ inside_nodes: AbstractSet[Path],
59
+ dirs_with_py: AbstractSet[Path],
60
+ pair_edges: PairEdges,
61
+ edge_names: EdgeNames,
62
+ file_scores: Mapping[Path, FileScore],
63
+ dir_scores: Mapping[Path, DirScore],
64
+ ) -> tuple[_NodeRow, ...]:
65
+ rows: list[_NodeRow] = []
66
+ for node in inside_nodes:
67
+ incoming_file_pairs: set[EdgeKey] = set()
68
+ importing_units: set[Path] = set()
69
+ name_units: dict[str, set[Path]] = {}
70
+ distinct_target_names: set[tuple[Path, str]] = set()
71
+ private = 0
72
+
73
+ for (src, tgt), file_pairs in pair_edges.items():
74
+ if tgt != node or src == node:
75
+ continue
76
+ importing_units.add(src)
77
+ incoming_file_pairs |= file_pairs
78
+ for f_rel, t_rel in file_pairs:
79
+ for name in edge_names.get((f_rel, t_rel), ()):
80
+ distinct_target_names.add((t_rel, name))
81
+ name_units.setdefault(name, set()).add(src)
82
+ if is_private_name(name):
83
+ private += 1
84
+
85
+ top_names = sorted(name_units.items(), key=lambda kv: (-len(kv[1]), kv[0]))[:5]
86
+ top_names_fmt = tuple(_NamedCount(name, len(units)) for name, units in top_names)
87
+
88
+ internal_pairs = {
89
+ (f_rel, t_rel)
90
+ for (f_rel, t_rel) in edge_names
91
+ if f_rel.is_relative_to(node) and t_rel.is_relative_to(node)
92
+ }
93
+
94
+ outgoing_pairs: set[EdgeKey] = set()
95
+ for (src, tgt), file_pairs in pair_edges.items():
96
+ if src == node and tgt != node:
97
+ outgoing_pairs |= file_pairs
98
+
99
+ init_split: _InitSplit | _NoInitFile
100
+ init_path = node / "__init__.py"
101
+ if node in dirs_with_py and (repo / init_path).is_file():
102
+ root = sum(1 for _, t_rel in incoming_file_pairs if t_rel == init_path)
103
+ init_split = _InitSplit(root, len(incoming_file_pairs) - root)
104
+ else:
105
+ init_split = _NoInitFile()
106
+
107
+ if node in dirs_with_py:
108
+ ds = dir_scores[node]
109
+ reach_a, reach_b, score = ds.reach_in, ds.reach_out, ds.score
110
+ else:
111
+ fs = file_scores[node]
112
+ reach_a, reach_b, score = fs.dependents, fs.dependencies, fs.score
113
+
114
+ rows.append(
115
+ _NodeRow(
116
+ node,
117
+ len(importing_units),
118
+ len(distinct_target_names),
119
+ top_names_fmt,
120
+ len(internal_pairs),
121
+ len(outgoing_pairs),
122
+ init_split,
123
+ private,
124
+ reach_a,
125
+ reach_b,
126
+ score,
127
+ )
128
+ )
129
+
130
+ rows.sort(key=lambda r: (-r.importing_units, str(r.node)))
131
+ return tuple(rows)
132
+
133
+
134
+ # an SCC with at most this many internal edges (one per ordered (src, tgt)
135
+ # child pair, not file pairs) lists every edge; above it, only mutual pairs
136
+ # are listed plus a count of the rest (see Edges for those).
137
+ _SCC_FULL_EDGE_LIMIT = 30
138
+
139
+
140
+ def render_summary(
141
+ repo: Path,
142
+ inside_nodes: AbstractSet[Path],
143
+ dirs_with_py: AbstractSet[Path],
144
+ pair_edges: PairEdges,
145
+ edge_names: EdgeNames,
146
+ label: Callable[[Path], str],
147
+ file_scores: Mapping[Path, FileScore],
148
+ dir_scores: Mapping[Path, DirScore],
149
+ ) -> tuple[str, ...]:
150
+ lines: list[str] = ["## Cycles", ""]
151
+ sccs = compute_sccs(inside_nodes, pair_edges)
152
+ if not sccs:
153
+ lines.append("no cycles")
154
+ else:
155
+ for scc in sccs:
156
+ lines.append(f"- {', '.join(label(m) for m in scc.members)}")
157
+ for mp in scc.mutual_pairs:
158
+ pair_str = f"{label(mp.a)} <-> {label(mp.b)}"
159
+ count_str = f"({mp.a_to_b_files}/{mp.b_to_a_files})"
160
+ lines.append(f" - {pair_str} {count_str}")
161
+ mutual_members = {(mp.a, mp.b) for mp in scc.mutual_pairs} | {
162
+ (mp.b, mp.a) for mp in scc.mutual_pairs
163
+ }
164
+ one_way = [e for e in scc.edges if (e.src, e.tgt) not in mutual_members]
165
+ if not scc.mutual_pairs or len(scc.edges) <= _SCC_FULL_EDGE_LIMIT:
166
+ # A->B->C->A with no mutual pair: the one-way edges are the
167
+ # cycle itself, so keep the full list instead of collapsing
168
+ # it. Small SCCs (<= _SCC_FULL_EDGE_LIMIT internal edges) also
169
+ # list every one-way edge instead of collapsing to a count.
170
+ for e in one_way:
171
+ lines.append(f" - {label(e.src)} -> {label(e.tgt)} ({e.files})")
172
+ elif one_way:
173
+ lines.append(
174
+ f" - {len(one_way)} more edges inside this cycle, see Edges."
175
+ )
176
+ lines.append("")
177
+ lines.append(
178
+ "`a <-> b (X/Y)` means X file pairs a->b, Y file pairs b->a "
179
+ + "(distinct importer,target file pairs, same count as an Edges heading)."
180
+ )
181
+ lines.append(
182
+ f"An SCC with at most {_SCC_FULL_EDGE_LIMIT} internal edges (one "
183
+ + "per ordered child pair) lists every one-way edge as "
184
+ + "`a -> b (N)`; above that, the one-way edges shrink to a count, "
185
+ + "see Edges, unless the SCC has no mutual pair."
186
+ )
187
+ lines.append("")
188
+
189
+ lines.append("## Node summary")
190
+ lines.append("")
191
+ rows = _compute_node_rows(
192
+ repo,
193
+ inside_nodes,
194
+ dirs_with_py,
195
+ pair_edges,
196
+ edge_names,
197
+ file_scores,
198
+ dir_scores,
199
+ )
200
+ lines.append(
201
+ "| node | importing units | names | internal | outgoing | root/internals "
202
+ + "| private | dependents/in | dependencies/out | score |"
203
+ )
204
+ lines.append("| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |")
205
+ for row in rows:
206
+ names_cell = str(row.distinct_names)
207
+ if row.top_names:
208
+ top_str = ", ".join(f"{nc.name} ({nc.count})" for nc in row.top_names)
209
+ names_cell = f"{row.distinct_names} ({top_str})"
210
+ root_cell = (
211
+ "-"
212
+ if isinstance(row.init_split, _NoInitFile)
213
+ else f"{row.init_split.root}/{row.init_split.internals}"
214
+ )
215
+ left = f"| {label(row.node)} | {row.importing_units} | {names_cell} |"
216
+ right = f" {row.internal} | {row.outgoing} | {root_cell} | {row.private} |"
217
+ reach_cell = f" {row.reach_a} | {row.reach_b} | {row.score} |"
218
+ lines.append(left + right + reach_cell)
219
+ lines.append("")
220
+ lines.append(
221
+ "- importing units: distinct other view nodes with an edge into this node"
222
+ )
223
+ lines.append(
224
+ "- names: distinct (target file, name) pairs imported from this node, "
225
+ + "then the top 5 names by importing-unit count"
226
+ )
227
+ lines.append(
228
+ "- internal: distinct (importer file, target file) pairs with both "
229
+ + "files inside this node"
230
+ )
231
+ lines.append(
232
+ "- outgoing: distinct (importer file, target file) pairs from this "
233
+ + "node to other view nodes"
234
+ )
235
+ lines.append(
236
+ "- root/internals: incoming file pairs into this node's own "
237
+ + '__init__.py vs into its deeper files ("-" for a single file or a '
238
+ + "namespace dir without __init__.py)"
239
+ )
240
+ lines.append(
241
+ "- private: (importer, target, name) triples from other nodes where "
242
+ + 'name starts with a single "_" (not dunder)'
243
+ )
244
+ lines.append(
245
+ "- dependents/in, dependencies/out, score: computed once repo-wide "
246
+ + "(not from this view's edges); for a file node, transitive "
247
+ + "dependents and dependencies and their product; for a directory "
248
+ + "node, files outside it that reach in/are reached out of it "
249
+ + "transitively, and their product"
250
+ )
251
+ lines.append("")
252
+ return tuple(lines)
@@ -0,0 +1,207 @@
1
+ """Mermaid and HTML views: one per directory, with a markdown link list to
2
+ zoom in/out, plus the node summary table (`_summary`)."""
3
+
4
+ import html
5
+ import os
6
+ from collections.abc import Callable, Mapping, Sequence
7
+ from collections.abc import Set as AbstractSet
8
+ from pathlib import Path
9
+
10
+ from pymodularity._parse.graph import EdgeKey, EdgeNames
11
+ from pymodularity._scores import DirScore, FileScore
12
+ from pymodularity._view_edges import PairEdges, PairKey, compute_inside_nodes
13
+ from pymodularity._views._summary import render_summary
14
+
15
+ # (link text, relative link target) for the markdown "Links:" list
16
+ _Link = tuple[str, str]
17
+
18
+
19
+ def _mermaid_id(path: Path) -> str:
20
+ s = str(path).replace("\\", "/")
21
+ return "n_" + "".join(c if c.isalnum() else "_" for c in s)
22
+
23
+
24
+ def _view_index_path(out_dir: Path, directory: Path, name: str = "index.md") -> Path:
25
+ if directory == Path("."):
26
+ return out_dir / name
27
+ return out_dir / directory / name
28
+
29
+
30
+ _MERMAID_CDN_URL = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"
31
+ # headroom above mermaid's defaults (maxTextSize 50000, maxEdges 500): the
32
+ # largest view seen in practice had a ~38000-char mermaid block with ~400
33
+ # edges, so double both defaults.
34
+ _MERMAID_MAX_TEXT_SIZE = 100_000
35
+ _MERMAID_MAX_EDGES = 1_000
36
+
37
+
38
+ def _build_mermaid_lines(
39
+ nodes: AbstractSet[Path],
40
+ ids: Mapping[Path, str],
41
+ outside_nodes: AbstractSet[Path],
42
+ label: Callable[[Path], str],
43
+ ordered_pairs: Sequence[tuple[PairKey, AbstractSet[EdgeKey]]],
44
+ ) -> tuple[str, ...]:
45
+ lines: list[str] = ["flowchart LR"]
46
+ lines.append(" classDef outside fill:#eee,stroke:#999,stroke-dasharray: 5 5;")
47
+ for n in sorted(nodes, key=str):
48
+ lines.append(f' {ids[n]}["{label(n)}"]')
49
+ for (src, tgt), file_pairs in ordered_pairs:
50
+ lines.append(f' {ids[src]} -->|"{len(file_pairs)}"| {ids[tgt]}')
51
+ if outside_nodes:
52
+ lines.append(
53
+ " class " + ",".join(sorted(ids[n] for n in outside_nodes)) + " outside;"
54
+ )
55
+ return tuple(lines)
56
+
57
+
58
+ def _write_html_view(
59
+ out_dir: Path,
60
+ directory: Path,
61
+ mermaid_lines: Sequence[str],
62
+ nodes: AbstractSet[Path],
63
+ dirs_with_py: AbstractSet[Path],
64
+ ) -> None:
65
+ html_path = _view_index_path(out_dir, directory, "index.html")
66
+ title = "repo root" if directory == Path(".") else str(directory)
67
+
68
+ body_lines = list(mermaid_lines)
69
+ for n in sorted(nodes, key=str):
70
+ if n in dirs_with_py:
71
+ target_html = _view_index_path(out_dir, n, "index.html")
72
+ rel_link = os.path.relpath(target_html, start=html_path.parent)
73
+ body_lines.append(f' click {_mermaid_id(n)} "{rel_link}"')
74
+ mermaid_src = html.escape("\n".join(body_lines))
75
+
76
+ parent_link_html = ""
77
+ if directory != Path("."):
78
+ parent_index = _view_index_path(out_dir, directory.parent, "index.html")
79
+ rel_link = os.path.relpath(parent_index, start=html_path.parent)
80
+ parent_link_html = f'<p><a href="{rel_link}">parent view</a></p>\n'
81
+
82
+ page = f"""<!DOCTYPE html>
83
+ <html>
84
+ <head>
85
+ <meta charset="utf-8">
86
+ <title>import graph: {html.escape(title)}</title>
87
+ <style>
88
+ pre.mermaid {{ overflow: auto; white-space: pre; }}
89
+ pre.mermaid svg {{ max-width: none !important; }}
90
+ </style>
91
+ </head>
92
+ <body>
93
+ <h1>import graph: {html.escape(title)}</h1>
94
+ {parent_link_html}<pre class="mermaid">
95
+ {mermaid_src}
96
+ </pre>
97
+ <script type="module">
98
+ import mermaid from "{_MERMAID_CDN_URL}";
99
+ mermaid.initialize({{
100
+ startOnLoad: true,
101
+ securityLevel: "loose",
102
+ maxTextSize: {_MERMAID_MAX_TEXT_SIZE},
103
+ maxEdges: {_MERMAID_MAX_EDGES},
104
+ flowchart: {{ useMaxWidth: false }}
105
+ }});
106
+ </script>
107
+ </body>
108
+ </html>
109
+ """
110
+ html_path.write_text(page, encoding="utf-8")
111
+
112
+
113
+ def write_view(
114
+ out_dir: Path,
115
+ repo: Path,
116
+ directory: Path,
117
+ dirs_with_py: AbstractSet[Path],
118
+ pair_edges: PairEdges,
119
+ edge_names: EdgeNames,
120
+ write_html: bool,
121
+ file_scores: Mapping[Path, FileScore],
122
+ dir_scores: Mapping[Path, DirScore],
123
+ ) -> None:
124
+ index_path = _view_index_path(out_dir, directory)
125
+ index_path.parent.mkdir(parents=True, exist_ok=True)
126
+
127
+ nodes: set[Path] = set()
128
+ for src, tgt in pair_edges:
129
+ nodes.add(src)
130
+ nodes.add(tgt)
131
+
132
+ ids = {n: _mermaid_id(n) for n in nodes}
133
+ inside_nodes = compute_inside_nodes(directory, pair_edges)
134
+ outside_nodes = nodes - inside_nodes
135
+
136
+ def label(n: Path) -> str:
137
+ return str(n) if n in outside_nodes else n.name
138
+
139
+ lines: list[str] = []
140
+ title = "repo root" if directory == Path(".") else str(directory)
141
+ lines.append(f"# import graph: {title}")
142
+ lines.append("")
143
+
144
+ links: list[_Link] = [] # (text, target_index_path) for nodes that have a view
145
+
146
+ if directory != Path("."):
147
+ parent = directory.parent
148
+ parent_index = _view_index_path(out_dir, parent)
149
+ rel_link = os.path.relpath(parent_index, start=index_path.parent)
150
+ lines.append(f"[parent view]({rel_link})")
151
+ lines.append("")
152
+
153
+ lines.extend(
154
+ render_summary(
155
+ repo,
156
+ inside_nodes,
157
+ dirs_with_py,
158
+ pair_edges,
159
+ edge_names,
160
+ label,
161
+ file_scores,
162
+ dir_scores,
163
+ )
164
+ )
165
+
166
+ ordered_pairs = sorted(
167
+ pair_edges.items(), key=lambda kv: (str(kv[0][0]), str(kv[0][1]))
168
+ )
169
+ mermaid_lines = _build_mermaid_lines(nodes, ids, outside_nodes, label, ordered_pairs)
170
+
171
+ lines.append("```mermaid")
172
+ lines.extend(mermaid_lines)
173
+
174
+ for n in sorted(nodes, key=str):
175
+ if n in dirs_with_py:
176
+ target_index = _view_index_path(out_dir, n)
177
+ rel_link = os.path.relpath(target_index, start=index_path.parent)
178
+ links.append((str(n), rel_link))
179
+ lines.append("```")
180
+ lines.append("")
181
+
182
+ if links:
183
+ lines.append("Links:")
184
+ lines.append("")
185
+ for text, rel_link in links:
186
+ lines.append(f"- [{text}]({rel_link})")
187
+ lines.append("")
188
+
189
+ if ordered_pairs:
190
+ lines.append("Edges:")
191
+ lines.append("")
192
+ for (src, tgt), file_pairs in ordered_pairs:
193
+ unit = "file pair" if len(file_pairs) == 1 else "file pairs"
194
+ lines.append(f"### {label(src)} -> {label(tgt)} ({len(file_pairs)} {unit})")
195
+ lines.append("")
196
+ for f_rel, t_rel in sorted(file_pairs, key=lambda p: (str(p[0]), str(p[1]))):
197
+ names = sorted(edge_names.get((f_rel, t_rel), ()))
198
+ if names:
199
+ lines.append(f"- {f_rel} -> {t_rel}: {', '.join(names)}")
200
+ else:
201
+ lines.append(f"- {f_rel} -> {t_rel}")
202
+ lines.append("")
203
+
204
+ index_path.write_text("\n".join(lines), encoding="utf-8")
205
+
206
+ if write_html:
207
+ _write_html_view(out_dir, directory, mermaid_lines, nodes, dirs_with_py)