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.
Files changed (75) hide show
  1. archunitpython/__init__.py +45 -0
  2. archunitpython/common/__init__.py +18 -0
  3. archunitpython/common/assertion/__init__.py +3 -0
  4. archunitpython/common/assertion/violation.py +21 -0
  5. archunitpython/common/error/__init__.py +3 -0
  6. archunitpython/common/error/errors.py +13 -0
  7. archunitpython/common/extraction/__init__.py +13 -0
  8. archunitpython/common/extraction/extract_graph.py +345 -0
  9. archunitpython/common/extraction/graph.py +39 -0
  10. archunitpython/common/fluentapi/__init__.py +3 -0
  11. archunitpython/common/fluentapi/checkable.py +28 -0
  12. archunitpython/common/logging/__init__.py +3 -0
  13. archunitpython/common/logging/types.py +18 -0
  14. archunitpython/common/pattern_matching.py +80 -0
  15. archunitpython/common/projection/__init__.py +30 -0
  16. archunitpython/common/projection/cycles/__init__.py +4 -0
  17. archunitpython/common/projection/cycles/cycle_utils.py +49 -0
  18. archunitpython/common/projection/cycles/cycles.py +26 -0
  19. archunitpython/common/projection/cycles/johnsons_apsp.py +110 -0
  20. archunitpython/common/projection/cycles/model.py +22 -0
  21. archunitpython/common/projection/cycles/tarjan_scc.py +86 -0
  22. archunitpython/common/projection/edge_projections.py +36 -0
  23. archunitpython/common/projection/project_cycles.py +85 -0
  24. archunitpython/common/projection/project_edges.py +43 -0
  25. archunitpython/common/projection/project_nodes.py +49 -0
  26. archunitpython/common/projection/types.py +40 -0
  27. archunitpython/common/regex_factory.py +76 -0
  28. archunitpython/common/types.py +29 -0
  29. archunitpython/common/util/__init__.py +3 -0
  30. archunitpython/common/util/declaration_detector.py +115 -0
  31. archunitpython/common/util/logger.py +100 -0
  32. archunitpython/files/__init__.py +3 -0
  33. archunitpython/files/assertion/__init__.py +28 -0
  34. archunitpython/files/assertion/custom_file_logic.py +107 -0
  35. archunitpython/files/assertion/cycle_free.py +29 -0
  36. archunitpython/files/assertion/depend_on_files.py +67 -0
  37. archunitpython/files/assertion/matching_files.py +64 -0
  38. archunitpython/files/fluentapi/__init__.py +3 -0
  39. archunitpython/files/fluentapi/files.py +403 -0
  40. archunitpython/metrics/__init__.py +3 -0
  41. archunitpython/metrics/assertion/__init__.py +0 -0
  42. archunitpython/metrics/assertion/metric_thresholds.py +51 -0
  43. archunitpython/metrics/calculation/__init__.py +0 -0
  44. archunitpython/metrics/calculation/count.py +148 -0
  45. archunitpython/metrics/calculation/distance.py +110 -0
  46. archunitpython/metrics/calculation/lcom.py +177 -0
  47. archunitpython/metrics/common/__init__.py +19 -0
  48. archunitpython/metrics/common/types.py +67 -0
  49. archunitpython/metrics/extraction/__init__.py +0 -0
  50. archunitpython/metrics/extraction/extract_class_info.py +246 -0
  51. archunitpython/metrics/fluentapi/__init__.py +3 -0
  52. archunitpython/metrics/fluentapi/export_utils.py +89 -0
  53. archunitpython/metrics/fluentapi/metrics.py +589 -0
  54. archunitpython/metrics/projection/__init__.py +0 -0
  55. archunitpython/py.typed +0 -0
  56. archunitpython/slices/__init__.py +3 -0
  57. archunitpython/slices/assertion/__init__.py +13 -0
  58. archunitpython/slices/assertion/admissible_edges.py +108 -0
  59. archunitpython/slices/fluentapi/__init__.py +3 -0
  60. archunitpython/slices/fluentapi/slices.py +220 -0
  61. archunitpython/slices/projection/__init__.py +8 -0
  62. archunitpython/slices/projection/slicing_projections.py +128 -0
  63. archunitpython/slices/uml/__init__.py +4 -0
  64. archunitpython/slices/uml/export_diagram.py +31 -0
  65. archunitpython/slices/uml/generate_rules.py +71 -0
  66. archunitpython/testing/__init__.py +3 -0
  67. archunitpython/testing/assertion.py +47 -0
  68. archunitpython/testing/common/__init__.py +4 -0
  69. archunitpython/testing/common/color_utils.py +57 -0
  70. archunitpython/testing/common/violation_factory.py +97 -0
  71. archunitpython/testing/pytest_plugin/__init__.py +0 -0
  72. archunitpython-1.0.0.dist-info/METADATA +660 -0
  73. archunitpython-1.0.0.dist-info/RECORD +75 -0
  74. archunitpython-1.0.0.dist-info/WHEEL +4 -0
  75. 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
@@ -0,0 +1,3 @@
1
+ from archunitpython.common.util.logger import CheckLogger, shared_logger
2
+
3
+ __all__ = ["CheckLogger", "shared_logger"]