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,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)
|