archunitpython 1.1.2__py3-none-any.whl → 1.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.
- archunitpython/__init__.py +9 -1
- archunitpython/common/extraction/extract_graph.py +114 -7
- archunitpython/graph/__init__.py +35 -0
- archunitpython/graph/graph_reporter.py +795 -0
- archunitpython/layers/__init__.py +8 -0
- archunitpython/layers/assertion/__init__.py +9 -0
- archunitpython/layers/assertion/layer_dependencies.py +83 -0
- archunitpython/layers/fluentapi/__init__.py +6 -0
- archunitpython/layers/fluentapi/layers.py +116 -0
- archunitpython/testing/common/violation_factory.py +10 -0
- {archunitpython-1.1.2.dist-info → archunitpython-1.2.0.dist-info}/METADATA +160 -3
- {archunitpython-1.1.2.dist-info → archunitpython-1.2.0.dist-info}/RECORD +14 -7
- {archunitpython-1.1.2.dist-info → archunitpython-1.2.0.dist-info}/WHEEL +0 -0
- {archunitpython-1.1.2.dist-info → archunitpython-1.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
"""Dependency graph querying and report generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import fnmatch
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import asdict, dataclass, replace
|
|
11
|
+
from html import escape as escape_html
|
|
12
|
+
from io import StringIO
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Literal
|
|
15
|
+
|
|
16
|
+
from archunitpython.common.extraction.extract_graph import extract_graph
|
|
17
|
+
from archunitpython.common.extraction.graph import Graph
|
|
18
|
+
from archunitpython.common.fluentapi.checkable import CheckOptions
|
|
19
|
+
from archunitpython.common.types import Pattern
|
|
20
|
+
|
|
21
|
+
GraphReportFormat = Literal["dot", "mermaid", "d2", "csv", "json", "html"]
|
|
22
|
+
|
|
23
|
+
DEFAULT_TITLE = "ArchUnitPython Dependency Graph"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class FolderDepthCollapse:
|
|
28
|
+
"""Collapse file nodes to their folder path up to a fixed depth."""
|
|
29
|
+
|
|
30
|
+
depth: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class PatternCollapse:
|
|
35
|
+
"""Collapse file nodes by applying a regular expression replacement."""
|
|
36
|
+
|
|
37
|
+
pattern: re.Pattern[str]
|
|
38
|
+
replacement: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
GraphCollapseStrategy = FolderDepthCollapse | PatternCollapse
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class GraphQueryOptions:
|
|
46
|
+
"""Options for selecting, collapsing, and rendering a dependency graph."""
|
|
47
|
+
|
|
48
|
+
include_external_dependencies: bool = False
|
|
49
|
+
include_self_dependencies: bool = False
|
|
50
|
+
focus: Pattern | None = None
|
|
51
|
+
focus_depth: int = 1
|
|
52
|
+
reachable_from: Pattern | None = None
|
|
53
|
+
dependents_of: Pattern | None = None
|
|
54
|
+
collapse: GraphCollapseStrategy | None = None
|
|
55
|
+
title: str | None = None
|
|
56
|
+
project_path: str | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class GraphReportNode:
|
|
61
|
+
"""A rendered graph node."""
|
|
62
|
+
|
|
63
|
+
id: str
|
|
64
|
+
label: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class GraphReportEdge:
|
|
69
|
+
"""A rendered graph edge, possibly aggregating multiple raw edges."""
|
|
70
|
+
|
|
71
|
+
source: str
|
|
72
|
+
target: str
|
|
73
|
+
count: int
|
|
74
|
+
external: bool
|
|
75
|
+
import_kinds: tuple[str, ...]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class GraphReportSummary:
|
|
80
|
+
"""Basic counts for a rendered graph snapshot."""
|
|
81
|
+
|
|
82
|
+
node_count: int
|
|
83
|
+
edge_count: int
|
|
84
|
+
raw_edge_count: int
|
|
85
|
+
external_edge_count: int
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class GraphReportSnapshot:
|
|
90
|
+
"""A dependency graph after filtering, collapsing, and aggregation."""
|
|
91
|
+
|
|
92
|
+
title: str
|
|
93
|
+
nodes: tuple[GraphReportNode, ...]
|
|
94
|
+
edges: tuple[GraphReportEdge, ...]
|
|
95
|
+
summary: GraphReportSummary
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class _DisplayEdge:
|
|
100
|
+
source: str
|
|
101
|
+
target: str
|
|
102
|
+
external: bool
|
|
103
|
+
import_kinds: tuple[object, ...]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def project_graph(project_path: str | None = None) -> ProjectGraphBuilder:
|
|
107
|
+
"""Create a builder for dependency graph reports."""
|
|
108
|
+
return ProjectGraphBuilder(project_path)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
dependency_graph = project_graph
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ProjectGraphBuilder:
|
|
115
|
+
"""Fluent builder for dependency graph reports."""
|
|
116
|
+
|
|
117
|
+
def __init__(
|
|
118
|
+
self,
|
|
119
|
+
project_path: str | None = None,
|
|
120
|
+
options: GraphQueryOptions | None = None,
|
|
121
|
+
check_options: CheckOptions | None = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
self._project_path = project_path
|
|
124
|
+
self._options = options or GraphQueryOptions()
|
|
125
|
+
self._check_options = check_options
|
|
126
|
+
|
|
127
|
+
def include_external_dependencies(self) -> "ProjectGraphBuilder":
|
|
128
|
+
"""Include imports to external modules in graph reports."""
|
|
129
|
+
return self._with_options(
|
|
130
|
+
replace(self._options, include_external_dependencies=True)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def include_self_dependencies(self) -> "ProjectGraphBuilder":
|
|
134
|
+
"""Include self edges used to keep files visible as graph nodes."""
|
|
135
|
+
return self._with_options(replace(self._options, include_self_dependencies=True))
|
|
136
|
+
|
|
137
|
+
def focus_on(self, pattern: Pattern, depth: int = 1) -> "ProjectGraphBuilder":
|
|
138
|
+
"""Keep matching nodes and their neighbors up to the given depth."""
|
|
139
|
+
return self._with_options(
|
|
140
|
+
replace(self._options, focus=pattern, focus_depth=depth)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def reachable_from(self, pattern: Pattern) -> "ProjectGraphBuilder":
|
|
144
|
+
"""Keep matching nodes and all dependencies reachable from them."""
|
|
145
|
+
return self._with_options(replace(self._options, reachable_from=pattern))
|
|
146
|
+
|
|
147
|
+
def dependents_of(self, pattern: Pattern) -> "ProjectGraphBuilder":
|
|
148
|
+
"""Keep matching nodes and all files that transitively depend on them."""
|
|
149
|
+
return self._with_options(replace(self._options, dependents_of=pattern))
|
|
150
|
+
|
|
151
|
+
def collapse_to_folder_depth(self, depth: int) -> "ProjectGraphBuilder":
|
|
152
|
+
"""Aggregate file nodes to folders at the requested depth."""
|
|
153
|
+
return self._with_options(
|
|
154
|
+
replace(self._options, collapse=FolderDepthCollapse(depth=depth))
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def collapse_by_pattern(
|
|
158
|
+
self,
|
|
159
|
+
pattern: str | re.Pattern[str],
|
|
160
|
+
replacement: str = r"\1",
|
|
161
|
+
) -> "ProjectGraphBuilder":
|
|
162
|
+
"""Aggregate nodes by applying a regular expression replacement."""
|
|
163
|
+
compiled = re.compile(pattern) if isinstance(pattern, str) else pattern
|
|
164
|
+
return self._with_options(
|
|
165
|
+
replace(
|
|
166
|
+
self._options,
|
|
167
|
+
collapse=PatternCollapse(pattern=compiled, replacement=replacement),
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def titled(self, title: str) -> "ProjectGraphBuilder":
|
|
172
|
+
"""Set the graph report title."""
|
|
173
|
+
return self._with_options(replace(self._options, title=title))
|
|
174
|
+
|
|
175
|
+
def with_check_options(
|
|
176
|
+
self, check_options: CheckOptions
|
|
177
|
+
) -> "ProjectGraphBuilder":
|
|
178
|
+
"""Use the given check options when extracting the graph."""
|
|
179
|
+
return ProjectGraphBuilder(self._project_path, self._options, check_options)
|
|
180
|
+
|
|
181
|
+
def snapshot(self) -> GraphReportSnapshot:
|
|
182
|
+
"""Return the filtered, collapsed graph snapshot."""
|
|
183
|
+
return GraphReporter.create_snapshot(self._get_graph(), self._report_options())
|
|
184
|
+
|
|
185
|
+
def to_dot(self) -> str:
|
|
186
|
+
"""Render the graph in DOT format."""
|
|
187
|
+
return GraphReporter.to_dot(self._get_graph(), self._report_options())
|
|
188
|
+
|
|
189
|
+
def to_mermaid(self) -> str:
|
|
190
|
+
"""Render the graph in Mermaid flowchart format."""
|
|
191
|
+
return GraphReporter.to_mermaid(self._get_graph(), self._report_options())
|
|
192
|
+
|
|
193
|
+
def to_d2(self) -> str:
|
|
194
|
+
"""Render the graph in D2 format."""
|
|
195
|
+
return GraphReporter.to_d2(self._get_graph(), self._report_options())
|
|
196
|
+
|
|
197
|
+
def to_csv(self) -> str:
|
|
198
|
+
"""Render the graph as CSV."""
|
|
199
|
+
return GraphReporter.to_csv(self._get_graph(), self._report_options())
|
|
200
|
+
|
|
201
|
+
def to_json(self) -> str:
|
|
202
|
+
"""Render the graph snapshot as JSON."""
|
|
203
|
+
return GraphReporter.to_json(self._get_graph(), self._report_options())
|
|
204
|
+
|
|
205
|
+
def to_html(self) -> str:
|
|
206
|
+
"""Render the graph as a self-contained HTML report."""
|
|
207
|
+
return GraphReporter.to_html(self._get_graph(), self._report_options())
|
|
208
|
+
|
|
209
|
+
def export_as_dot(self, output_path: str) -> None:
|
|
210
|
+
"""Write a DOT report to disk."""
|
|
211
|
+
GraphReporter.export_as_dot(
|
|
212
|
+
self._get_graph(), output_path, self._report_options()
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def export_as_mermaid(self, output_path: str) -> None:
|
|
216
|
+
"""Write a Mermaid report to disk."""
|
|
217
|
+
GraphReporter.export_as_mermaid(
|
|
218
|
+
self._get_graph(), output_path, self._report_options()
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def export_as_d2(self, output_path: str) -> None:
|
|
222
|
+
"""Write a D2 report to disk."""
|
|
223
|
+
GraphReporter.export_as_d2(self._get_graph(), output_path, self._report_options())
|
|
224
|
+
|
|
225
|
+
def export_as_csv(self, output_path: str) -> None:
|
|
226
|
+
"""Write a CSV report to disk."""
|
|
227
|
+
GraphReporter.export_as_csv(
|
|
228
|
+
self._get_graph(), output_path, self._report_options()
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def export_as_json(self, output_path: str) -> None:
|
|
232
|
+
"""Write a JSON report to disk."""
|
|
233
|
+
GraphReporter.export_as_json(
|
|
234
|
+
self._get_graph(), output_path, self._report_options()
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def export_as_html(self, output_path: str) -> None:
|
|
238
|
+
"""Write an HTML report to disk."""
|
|
239
|
+
GraphReporter.export_as_html(
|
|
240
|
+
self._get_graph(), output_path, self._report_options()
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def _with_options(self, options: GraphQueryOptions) -> "ProjectGraphBuilder":
|
|
244
|
+
return ProjectGraphBuilder(
|
|
245
|
+
self._project_path,
|
|
246
|
+
options,
|
|
247
|
+
self._check_options,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def _report_options(self) -> GraphQueryOptions:
|
|
251
|
+
return replace(self._options, project_path=self._project_path)
|
|
252
|
+
|
|
253
|
+
def _get_graph(self) -> Graph:
|
|
254
|
+
return extract_graph(self._project_path, options=self._check_options)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class GraphReporter:
|
|
258
|
+
"""Create dependency graph snapshots and render them in report formats."""
|
|
259
|
+
|
|
260
|
+
@staticmethod
|
|
261
|
+
def create_snapshot(
|
|
262
|
+
graph: Graph,
|
|
263
|
+
options: GraphQueryOptions | None = None,
|
|
264
|
+
) -> GraphReportSnapshot:
|
|
265
|
+
"""Create a queryable report snapshot from a raw dependency graph."""
|
|
266
|
+
opts = options or GraphQueryOptions()
|
|
267
|
+
display_graph = _to_display_edges(graph, opts.project_path)
|
|
268
|
+
filtered_by_external = (
|
|
269
|
+
display_graph
|
|
270
|
+
if opts.include_external_dependencies
|
|
271
|
+
else [edge for edge in display_graph if not edge.external]
|
|
272
|
+
)
|
|
273
|
+
selected_nodes = _select_nodes(filtered_by_external, opts)
|
|
274
|
+
display_edges = [
|
|
275
|
+
edge
|
|
276
|
+
for edge in filtered_by_external
|
|
277
|
+
if (opts.include_self_dependencies or edge.source != edge.target)
|
|
278
|
+
and edge.source in selected_nodes
|
|
279
|
+
and edge.target in selected_nodes
|
|
280
|
+
]
|
|
281
|
+
|
|
282
|
+
collapsed_nodes = {
|
|
283
|
+
_collapse_node(node, opts.collapse) for node in selected_nodes
|
|
284
|
+
}
|
|
285
|
+
edge_map: dict[tuple[str, str], GraphReportEdge] = {}
|
|
286
|
+
|
|
287
|
+
for edge in display_edges:
|
|
288
|
+
source = _collapse_node(edge.source, opts.collapse)
|
|
289
|
+
target = _collapse_node(edge.target, opts.collapse)
|
|
290
|
+
|
|
291
|
+
if not opts.include_self_dependencies and source == target:
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
key = (source, target)
|
|
295
|
+
existing = edge_map.get(key)
|
|
296
|
+
if existing is not None:
|
|
297
|
+
existing.count += 1
|
|
298
|
+
existing.external = existing.external or edge.external
|
|
299
|
+
existing.import_kinds = _unique_sorted_import_kinds(
|
|
300
|
+
(*existing.import_kinds, *edge.import_kinds)
|
|
301
|
+
)
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
edge_map[key] = GraphReportEdge(
|
|
305
|
+
source=source,
|
|
306
|
+
target=target,
|
|
307
|
+
count=1,
|
|
308
|
+
external=edge.external,
|
|
309
|
+
import_kinds=_unique_sorted_import_kinds(edge.import_kinds),
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
edges = tuple(sorted(edge_map.values(), key=lambda e: (e.source, e.target)))
|
|
313
|
+
edge_node_labels = [label for edge in edges for label in (edge.source, edge.target)]
|
|
314
|
+
labels = _unique_sorted((*collapsed_nodes, *edge_node_labels))
|
|
315
|
+
nodes = tuple(
|
|
316
|
+
GraphReportNode(id=f"n{index}", label=label)
|
|
317
|
+
for index, label in enumerate(labels)
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
return GraphReportSnapshot(
|
|
321
|
+
title=opts.title or DEFAULT_TITLE,
|
|
322
|
+
nodes=nodes,
|
|
323
|
+
edges=edges,
|
|
324
|
+
summary=GraphReportSummary(
|
|
325
|
+
node_count=len(nodes),
|
|
326
|
+
edge_count=len(edges),
|
|
327
|
+
raw_edge_count=len(display_edges),
|
|
328
|
+
external_edge_count=sum(1 for edge in display_edges if edge.external),
|
|
329
|
+
),
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
@staticmethod
|
|
333
|
+
def to_dot(graph: Graph, options: GraphQueryOptions | None = None) -> str:
|
|
334
|
+
"""Render a dependency graph in DOT format."""
|
|
335
|
+
snapshot = GraphReporter.create_snapshot(graph, options)
|
|
336
|
+
lines = ["digraph dependencies {", "\trankdir=LR;"]
|
|
337
|
+
|
|
338
|
+
for node in snapshot.nodes:
|
|
339
|
+
lines.append(f"\t{_quote_dot(node.label)};")
|
|
340
|
+
|
|
341
|
+
for edge in snapshot.edges:
|
|
342
|
+
label = f' [label="{edge.count}"]' if edge.count > 1 else ""
|
|
343
|
+
lines.append(
|
|
344
|
+
f"\t{_quote_dot(edge.source)} -> {_quote_dot(edge.target)}{label};"
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
lines.append("}")
|
|
348
|
+
return "\n".join(lines)
|
|
349
|
+
|
|
350
|
+
@staticmethod
|
|
351
|
+
def to_mermaid(graph: Graph, options: GraphQueryOptions | None = None) -> str:
|
|
352
|
+
"""Render a dependency graph in Mermaid flowchart format."""
|
|
353
|
+
snapshot = GraphReporter.create_snapshot(graph, options)
|
|
354
|
+
node_ids = {node.label: node.id for node in snapshot.nodes}
|
|
355
|
+
lines = ["flowchart LR"]
|
|
356
|
+
|
|
357
|
+
for node in snapshot.nodes:
|
|
358
|
+
lines.append(f' {node.id}["{_escape_mermaid_label(node.label)}"]')
|
|
359
|
+
|
|
360
|
+
for edge in snapshot.edges:
|
|
361
|
+
source = node_ids.get(edge.source)
|
|
362
|
+
target = node_ids.get(edge.target)
|
|
363
|
+
if source is None or target is None:
|
|
364
|
+
continue
|
|
365
|
+
label = f"|{edge.count}|" if edge.count > 1 else ""
|
|
366
|
+
lines.append(f" {source} -->{label} {target}")
|
|
367
|
+
|
|
368
|
+
return "\n".join(lines)
|
|
369
|
+
|
|
370
|
+
@staticmethod
|
|
371
|
+
def to_d2(graph: Graph, options: GraphQueryOptions | None = None) -> str:
|
|
372
|
+
"""Render a dependency graph in D2 format."""
|
|
373
|
+
snapshot = GraphReporter.create_snapshot(graph, options)
|
|
374
|
+
lines = [f"# {snapshot.title}"]
|
|
375
|
+
|
|
376
|
+
for node in snapshot.nodes:
|
|
377
|
+
lines.append(_quote_d2(node.label))
|
|
378
|
+
|
|
379
|
+
for edge in snapshot.edges:
|
|
380
|
+
label = f": {_quote_d2(str(edge.count))}" if edge.count > 1 else ""
|
|
381
|
+
lines.append(f"{_quote_d2(edge.source)} -> {_quote_d2(edge.target)}{label}")
|
|
382
|
+
|
|
383
|
+
return "\n".join(lines)
|
|
384
|
+
|
|
385
|
+
@staticmethod
|
|
386
|
+
def to_csv(graph: Graph, options: GraphQueryOptions | None = None) -> str:
|
|
387
|
+
"""Render a dependency graph as CSV."""
|
|
388
|
+
snapshot = GraphReporter.create_snapshot(graph, options)
|
|
389
|
+
output = StringIO()
|
|
390
|
+
writer = csv.writer(output, lineterminator="\n")
|
|
391
|
+
writer.writerow(["source", "target", "count", "external", "import_kinds"])
|
|
392
|
+
|
|
393
|
+
for edge in snapshot.edges:
|
|
394
|
+
writer.writerow(
|
|
395
|
+
[
|
|
396
|
+
edge.source,
|
|
397
|
+
edge.target,
|
|
398
|
+
str(edge.count),
|
|
399
|
+
str(edge.external).lower(),
|
|
400
|
+
"|".join(edge.import_kinds),
|
|
401
|
+
]
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
return output.getvalue().rstrip("\n")
|
|
405
|
+
|
|
406
|
+
@staticmethod
|
|
407
|
+
def to_json(graph: Graph, options: GraphQueryOptions | None = None) -> str:
|
|
408
|
+
"""Render a dependency graph snapshot as formatted JSON."""
|
|
409
|
+
return json.dumps(asdict(GraphReporter.create_snapshot(graph, options)), indent=2)
|
|
410
|
+
|
|
411
|
+
@staticmethod
|
|
412
|
+
def to_html(graph: Graph, options: GraphQueryOptions | None = None) -> str:
|
|
413
|
+
"""Render a dependency graph as an HTML report."""
|
|
414
|
+
snapshot = GraphReporter.create_snapshot(graph, options)
|
|
415
|
+
mermaid = GraphReporter.to_mermaid(graph, options)
|
|
416
|
+
dot = GraphReporter.to_dot(graph, options)
|
|
417
|
+
d2 = GraphReporter.to_d2(graph, options)
|
|
418
|
+
|
|
419
|
+
return f"""<!DOCTYPE html>
|
|
420
|
+
<html lang="en">
|
|
421
|
+
<head>
|
|
422
|
+
<meta charset="UTF-8">
|
|
423
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
424
|
+
<title>{escape_html(snapshot.title)}</title>
|
|
425
|
+
<style>
|
|
426
|
+
body {{
|
|
427
|
+
font-family: Arial, sans-serif;
|
|
428
|
+
margin: 0;
|
|
429
|
+
color: #1f2933;
|
|
430
|
+
background: #f8fafc;
|
|
431
|
+
}}
|
|
432
|
+
header {{ background: #102a43; color: white; padding: 24px 32px; }}
|
|
433
|
+
main {{ padding: 24px 32px; }}
|
|
434
|
+
h1 {{ margin: 0 0 8px; font-size: 28px; }}
|
|
435
|
+
h2 {{ margin-top: 32px; font-size: 20px; }}
|
|
436
|
+
.summary {{ display: flex; flex-wrap: wrap; gap: 12px; margin-top: 16px; }}
|
|
437
|
+
.metric {{
|
|
438
|
+
background: white;
|
|
439
|
+
border: 1px solid #d9e2ec;
|
|
440
|
+
border-radius: 6px;
|
|
441
|
+
padding: 12px 16px;
|
|
442
|
+
min-width: 140px;
|
|
443
|
+
}}
|
|
444
|
+
.metric strong {{ display: block; font-size: 24px; color: #102a43; }}
|
|
445
|
+
table {{
|
|
446
|
+
border-collapse: collapse;
|
|
447
|
+
width: 100%;
|
|
448
|
+
background: white;
|
|
449
|
+
border: 1px solid #d9e2ec;
|
|
450
|
+
}}
|
|
451
|
+
th, td {{
|
|
452
|
+
text-align: left;
|
|
453
|
+
border-bottom: 1px solid #d9e2ec;
|
|
454
|
+
padding: 8px 10px;
|
|
455
|
+
vertical-align: top;
|
|
456
|
+
}}
|
|
457
|
+
th {{ background: #eef2f7; font-weight: 700; }}
|
|
458
|
+
pre {{
|
|
459
|
+
background: #0b1220;
|
|
460
|
+
color: #e6edf3;
|
|
461
|
+
padding: 16px;
|
|
462
|
+
border-radius: 6px;
|
|
463
|
+
overflow: auto;
|
|
464
|
+
}}
|
|
465
|
+
pre.mermaid {{ background: white; color: #1f2933; border: 1px solid #d9e2ec; }}
|
|
466
|
+
details {{ margin: 16px 0; }}
|
|
467
|
+
summary {{ cursor: pointer; font-weight: 700; }}
|
|
468
|
+
.empty {{
|
|
469
|
+
color: #627d98;
|
|
470
|
+
background: white;
|
|
471
|
+
border: 1px solid #d9e2ec;
|
|
472
|
+
padding: 16px;
|
|
473
|
+
border-radius: 6px;
|
|
474
|
+
}}
|
|
475
|
+
</style>
|
|
476
|
+
</head>
|
|
477
|
+
<body>
|
|
478
|
+
<header>
|
|
479
|
+
<h1>{escape_html(snapshot.title)}</h1>
|
|
480
|
+
<div>Generated by ArchUnitPython graph reporting</div>
|
|
481
|
+
</header>
|
|
482
|
+
<main>
|
|
483
|
+
<section class="summary">
|
|
484
|
+
<div class="metric"><strong>{snapshot.summary.node_count}</strong>Nodes</div>
|
|
485
|
+
<div class="metric"><strong>{snapshot.summary.edge_count}</strong>Aggregated Edges</div>
|
|
486
|
+
<div class="metric"><strong>{snapshot.summary.raw_edge_count}</strong>Raw Edges</div>
|
|
487
|
+
<div class="metric">
|
|
488
|
+
<strong>{snapshot.summary.external_edge_count}</strong>External Edges
|
|
489
|
+
</div>
|
|
490
|
+
</section>
|
|
491
|
+
|
|
492
|
+
<h2>Dependencies</h2>
|
|
493
|
+
{_render_edge_table(snapshot)}
|
|
494
|
+
|
|
495
|
+
<h2>Mermaid Preview</h2>
|
|
496
|
+
<pre class="mermaid">{escape_html(mermaid)}</pre>
|
|
497
|
+
|
|
498
|
+
<details>
|
|
499
|
+
<summary>Mermaid Source</summary>
|
|
500
|
+
<pre>{escape_html(mermaid)}</pre>
|
|
501
|
+
</details>
|
|
502
|
+
|
|
503
|
+
<details>
|
|
504
|
+
<summary>DOT</summary>
|
|
505
|
+
<pre>{escape_html(dot)}</pre>
|
|
506
|
+
</details>
|
|
507
|
+
|
|
508
|
+
<details>
|
|
509
|
+
<summary>D2</summary>
|
|
510
|
+
<pre>{escape_html(d2)}</pre>
|
|
511
|
+
</details>
|
|
512
|
+
</main>
|
|
513
|
+
<script type="module">
|
|
514
|
+
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
|
|
515
|
+
mermaid.initialize({{ startOnLoad: true, securityLevel: 'loose' }});
|
|
516
|
+
</script>
|
|
517
|
+
</body>
|
|
518
|
+
</html>"""
|
|
519
|
+
|
|
520
|
+
@staticmethod
|
|
521
|
+
def export_as_dot(
|
|
522
|
+
graph: Graph,
|
|
523
|
+
output_path: str,
|
|
524
|
+
options: GraphQueryOptions | None = None,
|
|
525
|
+
) -> None:
|
|
526
|
+
"""Write a DOT report to disk."""
|
|
527
|
+
_write_report(output_path, GraphReporter.to_dot(graph, options))
|
|
528
|
+
|
|
529
|
+
@staticmethod
|
|
530
|
+
def export_as_mermaid(
|
|
531
|
+
graph: Graph,
|
|
532
|
+
output_path: str,
|
|
533
|
+
options: GraphQueryOptions | None = None,
|
|
534
|
+
) -> None:
|
|
535
|
+
"""Write a Mermaid report to disk."""
|
|
536
|
+
_write_report(output_path, GraphReporter.to_mermaid(graph, options))
|
|
537
|
+
|
|
538
|
+
@staticmethod
|
|
539
|
+
def export_as_d2(
|
|
540
|
+
graph: Graph,
|
|
541
|
+
output_path: str,
|
|
542
|
+
options: GraphQueryOptions | None = None,
|
|
543
|
+
) -> None:
|
|
544
|
+
"""Write a D2 report to disk."""
|
|
545
|
+
_write_report(output_path, GraphReporter.to_d2(graph, options))
|
|
546
|
+
|
|
547
|
+
@staticmethod
|
|
548
|
+
def export_as_csv(
|
|
549
|
+
graph: Graph,
|
|
550
|
+
output_path: str,
|
|
551
|
+
options: GraphQueryOptions | None = None,
|
|
552
|
+
) -> None:
|
|
553
|
+
"""Write a CSV report to disk."""
|
|
554
|
+
_write_report(output_path, GraphReporter.to_csv(graph, options))
|
|
555
|
+
|
|
556
|
+
@staticmethod
|
|
557
|
+
def export_as_json(
|
|
558
|
+
graph: Graph,
|
|
559
|
+
output_path: str,
|
|
560
|
+
options: GraphQueryOptions | None = None,
|
|
561
|
+
) -> None:
|
|
562
|
+
"""Write a JSON report to disk."""
|
|
563
|
+
_write_report(output_path, GraphReporter.to_json(graph, options))
|
|
564
|
+
|
|
565
|
+
@staticmethod
|
|
566
|
+
def export_as_html(
|
|
567
|
+
graph: Graph,
|
|
568
|
+
output_path: str,
|
|
569
|
+
options: GraphQueryOptions | None = None,
|
|
570
|
+
) -> None:
|
|
571
|
+
"""Write an HTML report to disk."""
|
|
572
|
+
_write_report(output_path, GraphReporter.to_html(graph, options))
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _to_display_edges(graph: Graph, project_path: str | None) -> list[_DisplayEdge]:
|
|
576
|
+
return [
|
|
577
|
+
_DisplayEdge(
|
|
578
|
+
source=_display_label(edge.source, project_path),
|
|
579
|
+
target=(
|
|
580
|
+
_normalize(edge.target)
|
|
581
|
+
if edge.external
|
|
582
|
+
else _display_label(edge.target, project_path)
|
|
583
|
+
),
|
|
584
|
+
external=edge.external,
|
|
585
|
+
import_kinds=edge.import_kinds,
|
|
586
|
+
)
|
|
587
|
+
for edge in graph
|
|
588
|
+
]
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _display_label(node: str, project_path: str | None) -> str:
|
|
592
|
+
normalized = _normalize(node)
|
|
593
|
+
if project_path is None or not os.path.isabs(node):
|
|
594
|
+
return normalized
|
|
595
|
+
|
|
596
|
+
try:
|
|
597
|
+
absolute_node = os.path.abspath(node)
|
|
598
|
+
absolute_root = os.path.abspath(project_path)
|
|
599
|
+
common_path = os.path.commonpath([absolute_node, absolute_root])
|
|
600
|
+
except ValueError:
|
|
601
|
+
return normalized
|
|
602
|
+
|
|
603
|
+
if os.path.normcase(common_path) != os.path.normcase(absolute_root):
|
|
604
|
+
return normalized
|
|
605
|
+
|
|
606
|
+
relative = os.path.relpath(absolute_node, absolute_root)
|
|
607
|
+
return "." if relative == "." else _normalize(relative)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _select_nodes(graph: list[_DisplayEdge], options: GraphQueryOptions) -> set[str]:
|
|
611
|
+
all_nodes = {node for edge in graph for node in (edge.source, edge.target)}
|
|
612
|
+
has_query = (
|
|
613
|
+
options.focus is not None
|
|
614
|
+
or options.reachable_from is not None
|
|
615
|
+
or options.dependents_of is not None
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
if not has_query:
|
|
619
|
+
return all_nodes
|
|
620
|
+
|
|
621
|
+
selected: set[str] = set()
|
|
622
|
+
|
|
623
|
+
if options.focus is not None:
|
|
624
|
+
selected.update(_expand_focus(graph, options.focus, options.focus_depth))
|
|
625
|
+
|
|
626
|
+
if options.reachable_from is not None:
|
|
627
|
+
selected.update(_walk_graph(graph, options.reachable_from, "outgoing"))
|
|
628
|
+
|
|
629
|
+
if options.dependents_of is not None:
|
|
630
|
+
selected.update(_walk_graph(graph, options.dependents_of, "incoming"))
|
|
631
|
+
|
|
632
|
+
return selected
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _expand_focus(
|
|
636
|
+
graph: list[_DisplayEdge],
|
|
637
|
+
pattern: Pattern,
|
|
638
|
+
depth: int,
|
|
639
|
+
) -> set[str]:
|
|
640
|
+
selected: set[str] = set()
|
|
641
|
+
queue: list[tuple[str, int]] = []
|
|
642
|
+
|
|
643
|
+
for node in {node for edge in graph for node in (edge.source, edge.target)}:
|
|
644
|
+
if _matches(pattern, node):
|
|
645
|
+
selected.add(node)
|
|
646
|
+
queue.append((node, 0))
|
|
647
|
+
|
|
648
|
+
while queue:
|
|
649
|
+
current, current_depth = queue.pop(0)
|
|
650
|
+
if current_depth >= depth:
|
|
651
|
+
continue
|
|
652
|
+
|
|
653
|
+
for neighbor in _neighbors_of(graph, current):
|
|
654
|
+
if neighbor not in selected:
|
|
655
|
+
selected.add(neighbor)
|
|
656
|
+
queue.append((neighbor, current_depth + 1))
|
|
657
|
+
|
|
658
|
+
return selected
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _walk_graph(
|
|
662
|
+
graph: list[_DisplayEdge],
|
|
663
|
+
pattern: Pattern,
|
|
664
|
+
direction: Literal["incoming", "outgoing"],
|
|
665
|
+
) -> set[str]:
|
|
666
|
+
selected: set[str] = set()
|
|
667
|
+
queue: list[str] = []
|
|
668
|
+
|
|
669
|
+
for node in {node for edge in graph for node in (edge.source, edge.target)}:
|
|
670
|
+
if _matches(pattern, node):
|
|
671
|
+
selected.add(node)
|
|
672
|
+
queue.append(node)
|
|
673
|
+
|
|
674
|
+
while queue:
|
|
675
|
+
current = queue.pop(0)
|
|
676
|
+
next_nodes: list[str] = []
|
|
677
|
+
for edge in graph:
|
|
678
|
+
if direction == "outgoing" and edge.source == current:
|
|
679
|
+
next_nodes.append(edge.target)
|
|
680
|
+
elif direction == "incoming" and edge.target == current:
|
|
681
|
+
next_nodes.append(edge.source)
|
|
682
|
+
|
|
683
|
+
for next_node in next_nodes:
|
|
684
|
+
if next_node not in selected:
|
|
685
|
+
selected.add(next_node)
|
|
686
|
+
queue.append(next_node)
|
|
687
|
+
|
|
688
|
+
return selected
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _neighbors_of(graph: list[_DisplayEdge], node: str) -> list[str]:
|
|
692
|
+
neighbors: list[str] = []
|
|
693
|
+
for edge in graph:
|
|
694
|
+
if edge.source == node and edge.target != node:
|
|
695
|
+
neighbors.append(edge.target)
|
|
696
|
+
elif edge.target == node and edge.source != node:
|
|
697
|
+
neighbors.append(edge.source)
|
|
698
|
+
return neighbors
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _collapse_node(node: str, strategy: GraphCollapseStrategy | None) -> str:
|
|
702
|
+
if strategy is None:
|
|
703
|
+
return node
|
|
704
|
+
|
|
705
|
+
normalized = _normalize(node)
|
|
706
|
+
|
|
707
|
+
if isinstance(strategy, PatternCollapse):
|
|
708
|
+
return strategy.pattern.sub(strategy.replacement, normalized)
|
|
709
|
+
|
|
710
|
+
depth = max(1, strategy.depth)
|
|
711
|
+
parts = [part for part in normalized.split("/") if part]
|
|
712
|
+
if len(parts) <= 1:
|
|
713
|
+
return normalized
|
|
714
|
+
|
|
715
|
+
folder_parts = parts[:-1]
|
|
716
|
+
if not folder_parts:
|
|
717
|
+
return normalized
|
|
718
|
+
|
|
719
|
+
return "/".join(folder_parts[:depth])
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _matches(pattern: Pattern, node: str) -> bool:
|
|
723
|
+
normalized = _normalize(node)
|
|
724
|
+
if isinstance(pattern, str):
|
|
725
|
+
return fnmatch.fnmatchcase(normalized, pattern)
|
|
726
|
+
return bool(pattern.search(normalized))
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _normalize(node: str) -> str:
|
|
730
|
+
return node.replace("\\", "/")
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _unique_sorted(values: tuple[str, ...] | list[str]) -> tuple[str, ...]:
|
|
734
|
+
return tuple(sorted(set(values)))
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _unique_sorted_import_kinds(values: tuple[object, ...]) -> tuple[str, ...]:
|
|
738
|
+
return tuple(
|
|
739
|
+
sorted(
|
|
740
|
+
{
|
|
741
|
+
str(getattr(value, "value", value))
|
|
742
|
+
for value in values
|
|
743
|
+
}
|
|
744
|
+
)
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _quote_dot(input_value: str) -> str:
|
|
749
|
+
return '"' + input_value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _quote_d2(input_value: str) -> str:
|
|
753
|
+
return '"' + input_value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _escape_mermaid_label(input_value: str) -> str:
|
|
757
|
+
return input_value.replace("\\", "\\\\").replace('"', "#quot;")
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _render_edge_table(snapshot: GraphReportSnapshot) -> str:
|
|
761
|
+
if not snapshot.edges:
|
|
762
|
+
return '<div class="empty">No dependency edges matched this graph query.</div>'
|
|
763
|
+
|
|
764
|
+
rows = "\n".join(
|
|
765
|
+
f""" <tr>
|
|
766
|
+
<td>{escape_html(edge.source)}</td>
|
|
767
|
+
<td>{escape_html(edge.target)}</td>
|
|
768
|
+
<td>{edge.count}</td>
|
|
769
|
+
<td>{"yes" if edge.external else "no"}</td>
|
|
770
|
+
<td>{escape_html(", ".join(edge.import_kinds))}</td>
|
|
771
|
+
</tr>"""
|
|
772
|
+
for edge in snapshot.edges
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
return f"""<table>
|
|
776
|
+
<thead>
|
|
777
|
+
<tr>
|
|
778
|
+
<th>Source</th>
|
|
779
|
+
<th>Target</th>
|
|
780
|
+
<th>Count</th>
|
|
781
|
+
<th>External</th>
|
|
782
|
+
<th>Import Kinds</th>
|
|
783
|
+
</tr>
|
|
784
|
+
</thead>
|
|
785
|
+
<tbody>
|
|
786
|
+
{rows}
|
|
787
|
+
</tbody>
|
|
788
|
+
</table>"""
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _write_report(output_path: str, content: str) -> None:
|
|
792
|
+
path = Path(output_path)
|
|
793
|
+
if path.parent != Path("."):
|
|
794
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
795
|
+
path.write_text(content, encoding="utf-8")
|