archunitpython 1.0.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 +45 -0
- archunitpython/common/__init__.py +18 -0
- archunitpython/common/assertion/__init__.py +3 -0
- archunitpython/common/assertion/violation.py +21 -0
- archunitpython/common/error/__init__.py +3 -0
- archunitpython/common/error/errors.py +13 -0
- archunitpython/common/extraction/__init__.py +13 -0
- archunitpython/common/extraction/extract_graph.py +345 -0
- archunitpython/common/extraction/graph.py +39 -0
- archunitpython/common/fluentapi/__init__.py +3 -0
- archunitpython/common/fluentapi/checkable.py +28 -0
- archunitpython/common/logging/__init__.py +3 -0
- archunitpython/common/logging/types.py +18 -0
- archunitpython/common/pattern_matching.py +80 -0
- archunitpython/common/projection/__init__.py +30 -0
- archunitpython/common/projection/cycles/__init__.py +4 -0
- archunitpython/common/projection/cycles/cycle_utils.py +49 -0
- archunitpython/common/projection/cycles/cycles.py +26 -0
- archunitpython/common/projection/cycles/johnsons_apsp.py +110 -0
- archunitpython/common/projection/cycles/model.py +22 -0
- archunitpython/common/projection/cycles/tarjan_scc.py +86 -0
- archunitpython/common/projection/edge_projections.py +36 -0
- archunitpython/common/projection/project_cycles.py +85 -0
- archunitpython/common/projection/project_edges.py +43 -0
- archunitpython/common/projection/project_nodes.py +49 -0
- archunitpython/common/projection/types.py +40 -0
- archunitpython/common/regex_factory.py +76 -0
- archunitpython/common/types.py +29 -0
- archunitpython/common/util/__init__.py +3 -0
- archunitpython/common/util/declaration_detector.py +115 -0
- archunitpython/common/util/logger.py +100 -0
- archunitpython/files/__init__.py +3 -0
- archunitpython/files/assertion/__init__.py +28 -0
- archunitpython/files/assertion/custom_file_logic.py +107 -0
- archunitpython/files/assertion/cycle_free.py +29 -0
- archunitpython/files/assertion/depend_on_files.py +67 -0
- archunitpython/files/assertion/matching_files.py +64 -0
- archunitpython/files/fluentapi/__init__.py +3 -0
- archunitpython/files/fluentapi/files.py +403 -0
- archunitpython/metrics/__init__.py +3 -0
- archunitpython/metrics/assertion/__init__.py +0 -0
- archunitpython/metrics/assertion/metric_thresholds.py +51 -0
- archunitpython/metrics/calculation/__init__.py +0 -0
- archunitpython/metrics/calculation/count.py +148 -0
- archunitpython/metrics/calculation/distance.py +110 -0
- archunitpython/metrics/calculation/lcom.py +177 -0
- archunitpython/metrics/common/__init__.py +19 -0
- archunitpython/metrics/common/types.py +67 -0
- archunitpython/metrics/extraction/__init__.py +0 -0
- archunitpython/metrics/extraction/extract_class_info.py +246 -0
- archunitpython/metrics/fluentapi/__init__.py +3 -0
- archunitpython/metrics/fluentapi/export_utils.py +89 -0
- archunitpython/metrics/fluentapi/metrics.py +589 -0
- archunitpython/metrics/projection/__init__.py +0 -0
- archunitpython/py.typed +0 -0
- archunitpython/slices/__init__.py +3 -0
- archunitpython/slices/assertion/__init__.py +13 -0
- archunitpython/slices/assertion/admissible_edges.py +108 -0
- archunitpython/slices/fluentapi/__init__.py +3 -0
- archunitpython/slices/fluentapi/slices.py +220 -0
- archunitpython/slices/projection/__init__.py +8 -0
- archunitpython/slices/projection/slicing_projections.py +128 -0
- archunitpython/slices/uml/__init__.py +4 -0
- archunitpython/slices/uml/export_diagram.py +31 -0
- archunitpython/slices/uml/generate_rules.py +71 -0
- archunitpython/testing/__init__.py +3 -0
- archunitpython/testing/assertion.py +47 -0
- archunitpython/testing/common/__init__.py +4 -0
- archunitpython/testing/common/color_utils.py +57 -0
- archunitpython/testing/common/violation_factory.py +97 -0
- archunitpython/testing/pytest_plugin/__init__.py +0 -0
- archunitpython-1.0.0.dist-info/METADATA +660 -0
- archunitpython-1.0.0.dist-info/RECORD +75 -0
- archunitpython-1.0.0.dist-info/WHEEL +4 -0
- archunitpython-1.0.0.dist-info/licenses/LICENSE +7 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Utility functions for cycle detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from archunitpython.common.projection.cycles.model import NumberEdge, NumberNode
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CycleUtils:
|
|
9
|
+
@staticmethod
|
|
10
|
+
def get_outgoing_neighbours(
|
|
11
|
+
current_node: NumberNode, graph: list[NumberNode]
|
|
12
|
+
) -> list[NumberNode]:
|
|
13
|
+
"""Get all nodes that current_node has outgoing edges to."""
|
|
14
|
+
node_map = {n.node: n for n in graph}
|
|
15
|
+
result = []
|
|
16
|
+
for edge in current_node.outgoing:
|
|
17
|
+
neighbour = node_map.get(edge.to_node)
|
|
18
|
+
if neighbour is not None:
|
|
19
|
+
result.append(neighbour)
|
|
20
|
+
return result
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def transform_edge_data(edges: list[NumberEdge]) -> list[NumberNode]:
|
|
24
|
+
"""Convert a list of edges into a list of nodes with in/out edges."""
|
|
25
|
+
unique_ids = CycleUtils.find_unique_nodes(edges)
|
|
26
|
+
nodes = []
|
|
27
|
+
for node_id in unique_ids:
|
|
28
|
+
nodes.append(
|
|
29
|
+
NumberNode(
|
|
30
|
+
node=node_id,
|
|
31
|
+
incoming=[e for e in edges if e.to_node == node_id],
|
|
32
|
+
outgoing=[e for e in edges if e.from_node == node_id],
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
return nodes
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def find_unique_nodes(edges: list[NumberEdge]) -> list[int]:
|
|
39
|
+
"""Find all unique node IDs in a list of edges, preserving order."""
|
|
40
|
+
seen: set[int] = set()
|
|
41
|
+
result: list[int] = []
|
|
42
|
+
for edge in edges:
|
|
43
|
+
if edge.from_node not in seen:
|
|
44
|
+
seen.add(edge.from_node)
|
|
45
|
+
result.append(edge.from_node)
|
|
46
|
+
if edge.to_node not in seen:
|
|
47
|
+
seen.add(edge.to_node)
|
|
48
|
+
result.append(edge.to_node)
|
|
49
|
+
return result
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Orchestrate cycle detection: Tarjan SCC -> Johnson per SCC."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from archunitpython.common.projection.cycles.johnsons_apsp import JohnsonsAPSP
|
|
6
|
+
from archunitpython.common.projection.cycles.model import NumberEdge
|
|
7
|
+
from archunitpython.common.projection.cycles.tarjan_scc import TarjanSCC
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def calculate_cycles(edges: list[NumberEdge]) -> list[list[NumberEdge]]:
|
|
11
|
+
"""Find all simple cycles using Tarjan SCC + Johnson's algorithm.
|
|
12
|
+
|
|
13
|
+
1. Find strongly connected components using Tarjan's algorithm
|
|
14
|
+
2. For each SCC with >1 edge, find all simple cycles using Johnson's
|
|
15
|
+
"""
|
|
16
|
+
cycles: list[list[NumberEdge]] = []
|
|
17
|
+
|
|
18
|
+
tarjan = TarjanSCC()
|
|
19
|
+
sccs = tarjan.find_strongly_connected_components(edges)
|
|
20
|
+
|
|
21
|
+
for scc in sccs:
|
|
22
|
+
if len(scc) > 1:
|
|
23
|
+
johnson = JohnsonsAPSP()
|
|
24
|
+
cycles.extend(johnson.find_simple_cycles(scc))
|
|
25
|
+
|
|
26
|
+
return cycles
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Johnson's algorithm for finding all simple cycles in a directed graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from archunitpython.common.projection.cycles.cycle_utils import CycleUtils
|
|
8
|
+
from archunitpython.common.projection.cycles.model import NumberEdge, NumberNode
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class _BlockedBy:
|
|
13
|
+
blocked: NumberNode
|
|
14
|
+
by: NumberNode
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JohnsonsAPSP:
|
|
18
|
+
"""Johnson's algorithm for finding all simple (elementary) cycles."""
|
|
19
|
+
|
|
20
|
+
def find_simple_cycles(self, edges: list[NumberEdge]) -> list[list[NumberEdge]]:
|
|
21
|
+
"""Find all simple cycles in the graph defined by edges.
|
|
22
|
+
|
|
23
|
+
Should be called on a strongly connected component for efficiency.
|
|
24
|
+
"""
|
|
25
|
+
self._blocked: list[NumberNode] = []
|
|
26
|
+
self._stack: list[NumberNode] = []
|
|
27
|
+
self._blocked_map: list[_BlockedBy] = []
|
|
28
|
+
self._graph = CycleUtils.transform_edge_data(edges)
|
|
29
|
+
self._start: NumberNode | None = None
|
|
30
|
+
self._cycles: list[list[NumberEdge]] = []
|
|
31
|
+
|
|
32
|
+
for node in list(self._graph):
|
|
33
|
+
self._start = node
|
|
34
|
+
self._stack.append(node)
|
|
35
|
+
self._blocked.append(node)
|
|
36
|
+
self._explore_neighbours(node)
|
|
37
|
+
self._remove_from_graph(node)
|
|
38
|
+
|
|
39
|
+
return self._cycles
|
|
40
|
+
|
|
41
|
+
def _explore_neighbours(self, current_node: NumberNode) -> None:
|
|
42
|
+
neighbours = CycleUtils.get_outgoing_neighbours(current_node, self._graph)
|
|
43
|
+
for neighbour in neighbours:
|
|
44
|
+
if self._found_cycle(neighbour):
|
|
45
|
+
self._cycles.append(self._build_cycle())
|
|
46
|
+
if not self._is_blocked(neighbour):
|
|
47
|
+
self._stack.append(neighbour)
|
|
48
|
+
self._blocked.append(neighbour)
|
|
49
|
+
self._explore_neighbours(neighbour)
|
|
50
|
+
|
|
51
|
+
self._stack.pop()
|
|
52
|
+
|
|
53
|
+
if self._is_part_of_current_start_cycle(current_node):
|
|
54
|
+
self._unblock(current_node)
|
|
55
|
+
else:
|
|
56
|
+
for neighbour in CycleUtils.get_outgoing_neighbours(
|
|
57
|
+
current_node, self._graph
|
|
58
|
+
):
|
|
59
|
+
if self._is_blocked(neighbour):
|
|
60
|
+
self._blocked_map.append(
|
|
61
|
+
_BlockedBy(blocked=current_node, by=neighbour)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def _unblock(self, node: NumberNode) -> None:
|
|
65
|
+
self._blocked = [n for n in self._blocked if n is not node]
|
|
66
|
+
to_remove: list[_BlockedBy] = []
|
|
67
|
+
for blocker in self._blocked_map:
|
|
68
|
+
if blocker.by is node:
|
|
69
|
+
self._unblock(blocker.blocked)
|
|
70
|
+
to_remove.append(blocker)
|
|
71
|
+
self._blocked_map = [b for b in self._blocked_map if b not in to_remove]
|
|
72
|
+
|
|
73
|
+
def _is_part_of_current_start_cycle(self, current_node: NumberNode) -> bool:
|
|
74
|
+
if self._start is None:
|
|
75
|
+
return False
|
|
76
|
+
for cycle in self._cycles:
|
|
77
|
+
if (
|
|
78
|
+
cycle[0].from_node == self._start.node
|
|
79
|
+
and any(e.from_node == current_node.node for e in cycle)
|
|
80
|
+
):
|
|
81
|
+
return True
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
def _is_blocked(self, node: NumberNode) -> bool:
|
|
85
|
+
return node in self._blocked
|
|
86
|
+
|
|
87
|
+
def _remove_from_graph(self, to_remove: NumberNode) -> None:
|
|
88
|
+
for node in self._graph:
|
|
89
|
+
node.incoming = [
|
|
90
|
+
e
|
|
91
|
+
for e in node.incoming
|
|
92
|
+
if e.from_node != to_remove.node and e.to_node != to_remove.node
|
|
93
|
+
]
|
|
94
|
+
node.outgoing = [
|
|
95
|
+
e
|
|
96
|
+
for e in node.outgoing
|
|
97
|
+
if e.from_node != to_remove.node and e.to_node != to_remove.node
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
def _found_cycle(self, current_node: NumberNode) -> bool:
|
|
101
|
+
return current_node is self._start
|
|
102
|
+
|
|
103
|
+
def _build_cycle(self) -> list[NumberEdge]:
|
|
104
|
+
node_ids = [n.node for n in self._stack]
|
|
105
|
+
cycle_edges: list[NumberEdge] = []
|
|
106
|
+
for i in range(len(node_ids)):
|
|
107
|
+
from_id = node_ids[i]
|
|
108
|
+
to_id = node_ids[i + 1] if i + 1 < len(node_ids) else self._start.node # type: ignore[union-attr]
|
|
109
|
+
cycle_edges.append(NumberEdge(from_node=from_id, to_node=to_id))
|
|
110
|
+
return cycle_edges
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Numeric graph model for cycle detection algorithms."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class NumberEdge:
|
|
10
|
+
"""An edge between two numerically-identified nodes."""
|
|
11
|
+
|
|
12
|
+
from_node: int
|
|
13
|
+
to_node: int
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class NumberNode:
|
|
18
|
+
"""A node with incoming and outgoing edges."""
|
|
19
|
+
|
|
20
|
+
node: int
|
|
21
|
+
incoming: list[NumberEdge] = field(default_factory=list)
|
|
22
|
+
outgoing: list[NumberEdge] = field(default_factory=list)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Tarjan's Strongly Connected Components algorithm."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from archunitpython.common.projection.cycles.model import NumberEdge
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class _Vertex:
|
|
9
|
+
"""Internal vertex representation for Tarjan's algorithm."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, node_id: int) -> None:
|
|
12
|
+
self.id = node_id
|
|
13
|
+
self.index = -1
|
|
14
|
+
self.lowlink = -1
|
|
15
|
+
self.neighbours: list[int] = []
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TarjanSCC:
|
|
19
|
+
"""Tarjan's algorithm for finding strongly connected components."""
|
|
20
|
+
|
|
21
|
+
def find_strongly_connected_components(
|
|
22
|
+
self, edges: list[NumberEdge]
|
|
23
|
+
) -> list[list[NumberEdge]]:
|
|
24
|
+
"""Find all strongly connected components in the graph.
|
|
25
|
+
|
|
26
|
+
Returns a list of edge lists, where each inner list contains
|
|
27
|
+
the edges belonging to one SCC. Only SCCs with edges are returned.
|
|
28
|
+
"""
|
|
29
|
+
self._graph: dict[int, _Vertex] = {}
|
|
30
|
+
self._index = 0
|
|
31
|
+
self._stack: list[_Vertex] = []
|
|
32
|
+
self._sccs: list[list[NumberEdge]] = []
|
|
33
|
+
self._edges = edges
|
|
34
|
+
|
|
35
|
+
self._init(edges)
|
|
36
|
+
|
|
37
|
+
for vertex in self._graph.values():
|
|
38
|
+
if vertex.index < 0:
|
|
39
|
+
self._visit(vertex)
|
|
40
|
+
|
|
41
|
+
return self._sccs
|
|
42
|
+
|
|
43
|
+
def _init(self, edges: list[NumberEdge]) -> None:
|
|
44
|
+
"""Build adjacency representation from edges."""
|
|
45
|
+
for edge in edges:
|
|
46
|
+
if edge.from_node not in self._graph:
|
|
47
|
+
self._graph[edge.from_node] = _Vertex(edge.from_node)
|
|
48
|
+
if edge.to_node not in self._graph:
|
|
49
|
+
self._graph[edge.to_node] = _Vertex(edge.to_node)
|
|
50
|
+
|
|
51
|
+
v = self._graph[edge.from_node]
|
|
52
|
+
if edge.to_node not in v.neighbours:
|
|
53
|
+
v.neighbours.append(edge.to_node)
|
|
54
|
+
|
|
55
|
+
def _visit(self, vertex: _Vertex) -> None:
|
|
56
|
+
"""DFS visit for Tarjan's algorithm."""
|
|
57
|
+
vertex.index = self._index
|
|
58
|
+
vertex.lowlink = self._index
|
|
59
|
+
self._index += 1
|
|
60
|
+
self._stack.append(vertex)
|
|
61
|
+
|
|
62
|
+
for neighbour_id in vertex.neighbours:
|
|
63
|
+
w = self._graph[neighbour_id]
|
|
64
|
+
if w.index < 0:
|
|
65
|
+
self._visit(w)
|
|
66
|
+
vertex.lowlink = min(vertex.lowlink, w.lowlink)
|
|
67
|
+
elif w in self._stack:
|
|
68
|
+
vertex.lowlink = min(vertex.lowlink, w.index)
|
|
69
|
+
|
|
70
|
+
if vertex.lowlink == vertex.index:
|
|
71
|
+
scc_vertices: list[_Vertex] = []
|
|
72
|
+
while True:
|
|
73
|
+
w = self._stack.pop()
|
|
74
|
+
scc_vertices.append(w)
|
|
75
|
+
if w.id == vertex.id:
|
|
76
|
+
break
|
|
77
|
+
|
|
78
|
+
if scc_vertices:
|
|
79
|
+
scc_ids = {v.id for v in scc_vertices}
|
|
80
|
+
scc_edges = [
|
|
81
|
+
e
|
|
82
|
+
for e in self._edges
|
|
83
|
+
if e.from_node in scc_ids and e.to_node in scc_ids
|
|
84
|
+
]
|
|
85
|
+
if scc_edges:
|
|
86
|
+
self._sccs.append(scc_edges)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Built-in MapFunction implementations for edge projection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from archunitpython.common.extraction.graph import Edge
|
|
6
|
+
from archunitpython.common.projection.types import MapFunction, MappedEdge
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def per_internal_edge() -> MapFunction:
|
|
10
|
+
"""Create a mapper that only passes internal (non-external) edges.
|
|
11
|
+
|
|
12
|
+
Self-referencing edges (source == target) are also filtered out.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def mapper(edge: Edge) -> MappedEdge | None:
|
|
16
|
+
if edge.external:
|
|
17
|
+
return None
|
|
18
|
+
if edge.source == edge.target:
|
|
19
|
+
return None
|
|
20
|
+
return MappedEdge(source_label=edge.source, target_label=edge.target)
|
|
21
|
+
|
|
22
|
+
return mapper
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def per_edge() -> MapFunction:
|
|
26
|
+
"""Create a mapper that passes all edges (including external).
|
|
27
|
+
|
|
28
|
+
Self-referencing edges are still filtered out.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def mapper(edge: Edge) -> MappedEdge | None:
|
|
32
|
+
if edge.source == edge.target:
|
|
33
|
+
return None
|
|
34
|
+
return MappedEdge(source_label=edge.source, target_label=edge.target)
|
|
35
|
+
|
|
36
|
+
return mapper
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""High-level cycle detection on projected graphs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from archunitpython.common.extraction.graph import Edge
|
|
6
|
+
from archunitpython.common.projection.cycles.cycles import calculate_cycles
|
|
7
|
+
from archunitpython.common.projection.cycles.model import NumberEdge
|
|
8
|
+
from archunitpython.common.projection.edge_projections import per_internal_edge
|
|
9
|
+
from archunitpython.common.projection.project_edges import project_edges
|
|
10
|
+
from archunitpython.common.projection.types import ProjectedEdge
|
|
11
|
+
|
|
12
|
+
ProjectedCycles = list[list[ProjectedEdge]]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def project_internal_cycles(graph: list[Edge]) -> ProjectedCycles:
|
|
16
|
+
"""Find cycles among internal edges of a raw graph."""
|
|
17
|
+
edges = project_edges(graph, per_internal_edge())
|
|
18
|
+
return project_cycles(edges)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def project_cycles(edges: list[ProjectedEdge]) -> ProjectedCycles:
|
|
22
|
+
"""Find cycles in a list of projected edges."""
|
|
23
|
+
return _CycleProcessor().find_cycles(edges)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _CycleProcessor:
|
|
27
|
+
"""Converts between string labels and numeric IDs for cycle detection."""
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self._label_to_id: dict[str, int] = {}
|
|
31
|
+
self._id_to_label: dict[int, str] = {}
|
|
32
|
+
self._source_edges: list[ProjectedEdge] = []
|
|
33
|
+
|
|
34
|
+
def find_cycles(self, edges: list[ProjectedEdge]) -> ProjectedCycles:
|
|
35
|
+
domain_edges = self._to_domain(edges)
|
|
36
|
+
cycles = calculate_cycles(domain_edges)
|
|
37
|
+
return self._from_domain(cycles)
|
|
38
|
+
|
|
39
|
+
def _to_domain(self, edges: list[ProjectedEdge]) -> list[NumberEdge]:
|
|
40
|
+
self._source_edges = edges
|
|
41
|
+
self._label_to_id = {}
|
|
42
|
+
self._id_to_label = {}
|
|
43
|
+
index = 0
|
|
44
|
+
|
|
45
|
+
for e in edges:
|
|
46
|
+
if e.source_label not in self._label_to_id:
|
|
47
|
+
self._label_to_id[e.source_label] = index
|
|
48
|
+
self._id_to_label[index] = e.source_label
|
|
49
|
+
index += 1
|
|
50
|
+
if e.target_label not in self._label_to_id:
|
|
51
|
+
self._label_to_id[e.target_label] = index
|
|
52
|
+
self._id_to_label[index] = e.target_label
|
|
53
|
+
index += 1
|
|
54
|
+
|
|
55
|
+
return [
|
|
56
|
+
NumberEdge(
|
|
57
|
+
from_node=self._label_to_id[e.source_label],
|
|
58
|
+
to_node=self._label_to_id[e.target_label],
|
|
59
|
+
)
|
|
60
|
+
for e in edges
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
def _from_domain(self, cycles: list[list[NumberEdge]]) -> ProjectedCycles:
|
|
64
|
+
result: ProjectedCycles = []
|
|
65
|
+
|
|
66
|
+
for cycle in cycles:
|
|
67
|
+
projected_cycle: list[ProjectedEdge] = []
|
|
68
|
+
for e in cycle:
|
|
69
|
+
source_label = self._id_to_label[e.from_node]
|
|
70
|
+
target_label = self._id_to_label[e.to_node]
|
|
71
|
+
found = next(
|
|
72
|
+
(
|
|
73
|
+
se
|
|
74
|
+
for se in self._source_edges
|
|
75
|
+
if se.source_label == source_label
|
|
76
|
+
and se.target_label == target_label
|
|
77
|
+
),
|
|
78
|
+
None,
|
|
79
|
+
)
|
|
80
|
+
if found:
|
|
81
|
+
projected_cycle.append(found)
|
|
82
|
+
if projected_cycle:
|
|
83
|
+
result.append(projected_cycle)
|
|
84
|
+
|
|
85
|
+
return result
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Project a raw graph into labeled edges with edge aggregation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
|
|
7
|
+
from archunitpython.common.extraction.graph import Edge
|
|
8
|
+
from archunitpython.common.projection.types import MapFunction, ProjectedEdge
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def project_edges(
|
|
12
|
+
graph: list[Edge],
|
|
13
|
+
mapper: MapFunction,
|
|
14
|
+
) -> list[ProjectedEdge]:
|
|
15
|
+
"""Apply a mapper to raw edges and group by (source_label, target_label).
|
|
16
|
+
|
|
17
|
+
Edges that map to the same (source_label, target_label) are cumulated.
|
|
18
|
+
Edges for which the mapper returns None are filtered out.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
graph: Raw edge list.
|
|
22
|
+
mapper: Function that maps Edge → MappedEdge or None.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
List of ProjectedEdge objects with cumulated raw edges.
|
|
26
|
+
"""
|
|
27
|
+
groups: dict[tuple[str, str], list[Edge]] = defaultdict(list)
|
|
28
|
+
|
|
29
|
+
for edge in graph:
|
|
30
|
+
mapped = mapper(edge)
|
|
31
|
+
if mapped is None:
|
|
32
|
+
continue
|
|
33
|
+
key = (mapped.source_label, mapped.target_label)
|
|
34
|
+
groups[key].append(edge)
|
|
35
|
+
|
|
36
|
+
return [
|
|
37
|
+
ProjectedEdge(
|
|
38
|
+
source_label=source_label,
|
|
39
|
+
target_label=target_label,
|
|
40
|
+
cumulated_edges=edges,
|
|
41
|
+
)
|
|
42
|
+
for (source_label, target_label), edges in groups.items()
|
|
43
|
+
]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Project a raw graph into nodes with incoming/outgoing edges."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
|
|
7
|
+
from archunitpython.common.extraction.graph import Edge
|
|
8
|
+
from archunitpython.common.projection.types import ProjectedNode
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def project_to_nodes(
|
|
12
|
+
graph: list[Edge],
|
|
13
|
+
*,
|
|
14
|
+
include_externals: bool = False,
|
|
15
|
+
) -> list[ProjectedNode]:
|
|
16
|
+
"""Group edges into nodes with incoming and outgoing edge lists.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
graph: Raw edge list.
|
|
20
|
+
include_externals: If True, include nodes for external dependencies.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
List of ProjectedNode objects.
|
|
24
|
+
"""
|
|
25
|
+
incoming: dict[str, list[Edge]] = defaultdict(list)
|
|
26
|
+
outgoing: dict[str, list[Edge]] = defaultdict(list)
|
|
27
|
+
all_labels: set[str] = set()
|
|
28
|
+
|
|
29
|
+
for edge in graph:
|
|
30
|
+
if edge.external and not include_externals:
|
|
31
|
+
# Still record the source (internal file)
|
|
32
|
+
all_labels.add(edge.source)
|
|
33
|
+
outgoing[edge.source].append(edge)
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
all_labels.add(edge.source)
|
|
37
|
+
all_labels.add(edge.target)
|
|
38
|
+
outgoing[edge.source].append(edge)
|
|
39
|
+
if edge.source != edge.target: # Don't count self-edges as incoming
|
|
40
|
+
incoming[edge.target].append(edge)
|
|
41
|
+
|
|
42
|
+
return [
|
|
43
|
+
ProjectedNode(
|
|
44
|
+
label=label,
|
|
45
|
+
incoming=incoming.get(label, []),
|
|
46
|
+
outgoing=[e for e in outgoing.get(label, []) if e.source != e.target],
|
|
47
|
+
)
|
|
48
|
+
for label in sorted(all_labels)
|
|
49
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Types for graph projections."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from archunitpython.common.extraction.graph import Edge
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ProjectedNode:
|
|
13
|
+
"""A node in a projected graph with its incoming/outgoing edges."""
|
|
14
|
+
|
|
15
|
+
label: str
|
|
16
|
+
incoming: list[Edge] = field(default_factory=list)
|
|
17
|
+
outgoing: list[Edge] = field(default_factory=list)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class MappedEdge:
|
|
22
|
+
"""An edge after label mapping."""
|
|
23
|
+
|
|
24
|
+
source_label: str
|
|
25
|
+
target_label: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ProjectedEdge:
|
|
30
|
+
"""An edge between two labeled nodes, aggregating underlying raw edges."""
|
|
31
|
+
|
|
32
|
+
source_label: str
|
|
33
|
+
target_label: str
|
|
34
|
+
cumulated_edges: list[Edge] = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
MapFunction = Callable[[Edge], MappedEdge | None]
|
|
38
|
+
"""A function that maps a raw Edge to a MappedEdge (or None to filter it out)."""
|
|
39
|
+
|
|
40
|
+
ProjectedGraph = list[ProjectedEdge]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Factory for creating Filter objects from glob patterns or regex."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from archunitpython.common.types import Filter, Pattern, PatternMatchingOptions
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _glob_to_regex(pattern: str) -> re.Pattern[str]:
|
|
12
|
+
"""Convert a glob pattern to a compiled regex.
|
|
13
|
+
|
|
14
|
+
Uses fnmatch.translate() which converts shell-style wildcards to regex.
|
|
15
|
+
"""
|
|
16
|
+
regex_str = fnmatch.translate(pattern)
|
|
17
|
+
return re.compile(regex_str)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _escape_regex(s: str) -> str:
|
|
21
|
+
"""Escape special regex characters in a string."""
|
|
22
|
+
return re.escape(s)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _pattern_to_regex(pattern: Pattern) -> re.Pattern[str]:
|
|
26
|
+
"""Convert a Pattern (str glob or compiled regex) to a compiled regex."""
|
|
27
|
+
if isinstance(pattern, re.Pattern):
|
|
28
|
+
return pattern
|
|
29
|
+
return _glob_to_regex(pattern)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RegexFactory:
|
|
33
|
+
"""Factory for creating Filter objects from patterns."""
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def filename_matcher(name: Pattern) -> Filter:
|
|
37
|
+
"""Create a filter that matches against the filename only (no directory)."""
|
|
38
|
+
return Filter(
|
|
39
|
+
regexp=_pattern_to_regex(name),
|
|
40
|
+
options=PatternMatchingOptions(target="filename"),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def classname_matcher(name: Pattern) -> Filter:
|
|
45
|
+
"""Create a filter that matches against class names."""
|
|
46
|
+
return Filter(
|
|
47
|
+
regexp=_pattern_to_regex(name),
|
|
48
|
+
options=PatternMatchingOptions(target="classname"),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def folder_matcher(folder: Pattern) -> Filter:
|
|
53
|
+
"""Create a filter that matches against the directory path (no filename)."""
|
|
54
|
+
return Filter(
|
|
55
|
+
regexp=_pattern_to_regex(folder),
|
|
56
|
+
options=PatternMatchingOptions(target="path-no-filename"),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def path_matcher(path: Pattern) -> Filter:
|
|
61
|
+
"""Create a filter that matches against the full file path."""
|
|
62
|
+
return Filter(
|
|
63
|
+
regexp=_pattern_to_regex(path),
|
|
64
|
+
options=PatternMatchingOptions(target="path"),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def exact_file_matcher(file_path: str) -> Filter:
|
|
69
|
+
"""Create a filter that matches an exact file path."""
|
|
70
|
+
normalized = file_path.replace("\\", "/")
|
|
71
|
+
escaped = _escape_regex(normalized)
|
|
72
|
+
regexp = re.compile(f"^{escaped}$")
|
|
73
|
+
return Filter(
|
|
74
|
+
regexp=regexp,
|
|
75
|
+
options=PatternMatchingOptions(target="path"),
|
|
76
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Core type definitions for pattern matching and filtering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Literal, Union
|
|
8
|
+
|
|
9
|
+
Pattern = Union[str, re.Pattern[str]]
|
|
10
|
+
"""A pattern can be a glob string or a compiled regex."""
|
|
11
|
+
|
|
12
|
+
MatchTarget = Literal["filename", "path", "path-no-filename", "classname"]
|
|
13
|
+
MatchType = Literal["exact", "partial"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class PatternMatchingOptions:
|
|
18
|
+
"""Options controlling how a pattern is matched against file paths."""
|
|
19
|
+
|
|
20
|
+
target: MatchTarget = "path"
|
|
21
|
+
matching: MatchType = "partial"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Filter:
|
|
26
|
+
"""A compiled regex filter with matching options."""
|
|
27
|
+
|
|
28
|
+
regexp: re.Pattern[str]
|
|
29
|
+
options: PatternMatchingOptions
|