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,108 @@
1
+ """Violation gathering for slice-level architecture rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from archunitpython.common.assertion.violation import Violation
8
+ from archunitpython.common.projection.types import ProjectedEdge
9
+ from archunitpython.slices.uml.generate_rules import Rule
10
+
11
+
12
+ @dataclass
13
+ class CoherenceOptions:
14
+ """Options for architecture coherence checking."""
15
+
16
+ ignore_orphan_slices: bool = False
17
+ ignore_external_slices: bool = False
18
+
19
+
20
+ @dataclass
21
+ class ViolatingEdge(Violation):
22
+ """A slice dependency that violates the architecture rules."""
23
+
24
+ rule: Rule | None
25
+ projected_edge: ProjectedEdge
26
+ is_negated: bool = False
27
+
28
+
29
+ def gather_violations(
30
+ edges: list[ProjectedEdge],
31
+ rules: list[Rule],
32
+ ) -> list[Violation]:
33
+ """Check for forbidden dependencies (used with shouldNot).
34
+
35
+ Args:
36
+ edges: Projected dependency edges between slices.
37
+ rules: Forbidden dependency rules.
38
+
39
+ Returns:
40
+ Violations for any edge that matches a forbidden rule.
41
+ """
42
+ violations: list[Violation] = []
43
+
44
+ for edge in edges:
45
+ for rule in rules:
46
+ if edge.source_label == rule.source and edge.target_label == rule.target:
47
+ violations.append(
48
+ ViolatingEdge(
49
+ rule=rule,
50
+ projected_edge=edge,
51
+ is_negated=True,
52
+ )
53
+ )
54
+
55
+ return violations
56
+
57
+
58
+ def gather_positive_violations(
59
+ edges: list[ProjectedEdge],
60
+ rules: list[Rule],
61
+ contained_nodes: list[str],
62
+ coherence_options: CoherenceOptions | None = None,
63
+ ) -> list[Violation]:
64
+ """Check that all dependencies are allowed by rules (used with should).
65
+
66
+ Args:
67
+ edges: Projected dependency edges between slices.
68
+ rules: Allowed dependency rules from diagram.
69
+ contained_nodes: All declared component names in the diagram.
70
+ coherence_options: Options for handling orphan/external slices.
71
+
72
+ Returns:
73
+ Violations for any edge not covered by the rules.
74
+ """
75
+ violations: list[Violation] = []
76
+ opts = coherence_options or CoherenceOptions()
77
+
78
+ # Build set of allowed edges (including self-dependencies)
79
+ allowed = {(r.source, r.target) for r in rules}
80
+
81
+ for edge in edges:
82
+ source = edge.source_label
83
+ target = edge.target_label
84
+
85
+ # Self-dependency is always allowed
86
+ if source == target:
87
+ continue
88
+
89
+ # Check if source/target are known components
90
+ source_known = source in contained_nodes
91
+ target_known = target in contained_nodes
92
+
93
+ if not source_known and opts.ignore_orphan_slices:
94
+ continue
95
+ if not target_known and opts.ignore_orphan_slices:
96
+ continue
97
+
98
+ # Check if the edge is allowed
99
+ if (source, target) not in allowed:
100
+ violations.append(
101
+ ViolatingEdge(
102
+ rule=None,
103
+ projected_edge=edge,
104
+ is_negated=False,
105
+ )
106
+ )
107
+
108
+ return violations
@@ -0,0 +1,3 @@
1
+ from archunitpython.slices.fluentapi.slices import project_slices
2
+
3
+ __all__ = ["project_slices"]
@@ -0,0 +1,220 @@
1
+ """Fluent API builder chain for slice-level architecture rules.
2
+
3
+ Usage:
4
+ project_slices('src/')
5
+ .defined_by('src/(**)/')
6
+ .should()
7
+ .adhere_to_diagram(puml_content)
8
+ .check()
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+
15
+ from archunitpython.common.assertion.violation import Violation
16
+ from archunitpython.common.extraction.extract_graph import extract_graph
17
+ from archunitpython.common.fluentapi.checkable import CheckOptions
18
+ from archunitpython.common.projection.project_edges import project_edges
19
+ from archunitpython.common.projection.types import MapFunction
20
+ from archunitpython.slices.assertion.admissible_edges import (
21
+ CoherenceOptions,
22
+ gather_positive_violations,
23
+ gather_violations,
24
+ )
25
+ from archunitpython.slices.projection.slicing_projections import (
26
+ slice_by_pattern,
27
+ slice_by_regex,
28
+ )
29
+ from archunitpython.slices.uml.generate_rules import Rule, generate_rule
30
+
31
+
32
+ def project_slices(project_path: str | None = None) -> "SliceConditionBuilder":
33
+ """Entry point for slice-level architecture rules.
34
+
35
+ Args:
36
+ project_path: Root directory of the project to analyze.
37
+ """
38
+ return SliceConditionBuilder(project_path)
39
+
40
+
41
+ class SliceConditionBuilder:
42
+ """Initial builder - define how files are grouped into slices."""
43
+
44
+ def __init__(self, project_path: str | None = None) -> None:
45
+ self._project_path = project_path
46
+ self._pattern: str | None = None
47
+ self._regex: re.Pattern[str] | None = None
48
+
49
+ def defined_by(self, pattern: str) -> "SliceConditionBuilder":
50
+ """Define slices using a path pattern with (**) placeholder.
51
+
52
+ Example: 'src/(**)/' groups files by the directory after src/.
53
+ """
54
+ self._pattern = pattern
55
+ return self
56
+
57
+ def defined_by_regex(self, regex: re.Pattern[str]) -> "SliceConditionBuilder":
58
+ """Define slices using a regex with a capture group."""
59
+ self._regex = regex
60
+ return self
61
+
62
+ def should(self) -> "PositiveConditionBuilder":
63
+ """Begin positive assertion (slices SHOULD ...)."""
64
+ return PositiveConditionBuilder(
65
+ self._project_path, self._pattern, self._regex
66
+ )
67
+
68
+ def should_not(self) -> "NegativeConditionBuilder":
69
+ """Begin negative assertion (slices SHOULD NOT ...)."""
70
+ return NegativeConditionBuilder(
71
+ self._project_path, self._pattern, self._regex
72
+ )
73
+
74
+
75
+ class PositiveConditionBuilder:
76
+ """Positive assertion builder for slices."""
77
+
78
+ def __init__(
79
+ self,
80
+ project_path: str | None,
81
+ pattern: str | None,
82
+ regex: re.Pattern[str] | None,
83
+ ) -> None:
84
+ self._project_path = project_path
85
+ self._pattern = pattern
86
+ self._regex = regex
87
+ self._coherence_options = CoherenceOptions()
88
+
89
+ def ignoring_orphan_slices(self) -> "PositiveConditionBuilder":
90
+ """Ignore slices not declared in the diagram."""
91
+ self._coherence_options = CoherenceOptions(
92
+ ignore_orphan_slices=True,
93
+ ignore_external_slices=self._coherence_options.ignore_external_slices,
94
+ )
95
+ return self
96
+
97
+ def ignoring_external_slices(self) -> "PositiveConditionBuilder":
98
+ """Ignore external dependency slices."""
99
+ self._coherence_options = CoherenceOptions(
100
+ ignore_orphan_slices=self._coherence_options.ignore_orphan_slices,
101
+ ignore_external_slices=True,
102
+ )
103
+ return self
104
+
105
+ def adhere_to_diagram(self, puml_content: str) -> "PositiveSliceCondition":
106
+ """Assert that slices adhere to a PlantUML diagram."""
107
+ return PositiveSliceCondition(
108
+ self._project_path,
109
+ self._pattern,
110
+ self._regex,
111
+ puml_content,
112
+ self._coherence_options,
113
+ )
114
+
115
+ def adhere_to_diagram_in_file(self, file_path: str) -> "PositiveSliceCondition":
116
+ """Assert that slices adhere to a diagram loaded from a file."""
117
+ with open(file_path, "r", encoding="utf-8") as f:
118
+ puml_content = f.read()
119
+ return self.adhere_to_diagram(puml_content)
120
+
121
+
122
+ class NegativeConditionBuilder:
123
+ """Negative assertion builder for slices."""
124
+
125
+ def __init__(
126
+ self,
127
+ project_path: str | None,
128
+ pattern: str | None,
129
+ regex: re.Pattern[str] | None,
130
+ ) -> None:
131
+ self._project_path = project_path
132
+ self._pattern = pattern
133
+ self._regex = regex
134
+ self._forbidden_deps: list[tuple[str, str]] = []
135
+
136
+ def contain_dependency(
137
+ self, source: str, target: str
138
+ ) -> "NegativeSliceCondition":
139
+ """Assert that a specific dependency should NOT exist."""
140
+ return NegativeSliceCondition(
141
+ self._project_path,
142
+ self._pattern,
143
+ self._regex,
144
+ source,
145
+ target,
146
+ )
147
+
148
+
149
+ class PositiveSliceCondition:
150
+ """Checkable that verifies slices adhere to a diagram."""
151
+
152
+ def __init__(
153
+ self,
154
+ project_path: str | None,
155
+ pattern: str | None,
156
+ regex: re.Pattern[str] | None,
157
+ puml_content: str,
158
+ coherence_options: CoherenceOptions,
159
+ ) -> None:
160
+ self._project_path = project_path
161
+ self._pattern = pattern
162
+ self._regex = regex
163
+ self._puml_content = puml_content
164
+ self._coherence_options = coherence_options
165
+
166
+ def check(self, options: CheckOptions | None = None) -> list[Violation]:
167
+ graph = extract_graph(self._project_path, options=options)
168
+ rules, contained_nodes = generate_rule(self._puml_content)
169
+
170
+ mapper = self._get_mapper()
171
+ edges = project_edges(graph, mapper)
172
+
173
+ return gather_positive_violations(
174
+ edges, rules, contained_nodes, self._coherence_options
175
+ )
176
+
177
+ def _get_mapper(self) -> MapFunction:
178
+ if self._pattern:
179
+ return slice_by_pattern(self._pattern)
180
+ elif self._regex:
181
+ return slice_by_regex(self._regex)
182
+ from archunitpython.slices.projection.slicing_projections import identity
183
+
184
+ return identity()
185
+
186
+
187
+ class NegativeSliceCondition:
188
+ """Checkable that verifies a specific dependency does NOT exist."""
189
+
190
+ def __init__(
191
+ self,
192
+ project_path: str | None,
193
+ pattern: str | None,
194
+ regex: re.Pattern[str] | None,
195
+ source: str,
196
+ target: str,
197
+ ) -> None:
198
+ self._project_path = project_path
199
+ self._pattern = pattern
200
+ self._regex = regex
201
+ self._source = source
202
+ self._target = target
203
+
204
+ def check(self, options: CheckOptions | None = None) -> list[Violation]:
205
+ graph = extract_graph(self._project_path, options=options)
206
+
207
+ mapper = self._get_mapper()
208
+ edges = project_edges(graph, mapper)
209
+
210
+ forbidden_rule = Rule(source=self._source, target=self._target)
211
+ return gather_violations(edges, [forbidden_rule])
212
+
213
+ def _get_mapper(self) -> MapFunction:
214
+ if self._pattern:
215
+ return slice_by_pattern(self._pattern)
216
+ elif self._regex:
217
+ return slice_by_regex(self._regex)
218
+ from archunitpython.slices.projection.slicing_projections import identity
219
+
220
+ return identity()
@@ -0,0 +1,8 @@
1
+ from archunitpython.slices.projection.slicing_projections import (
2
+ identity,
3
+ slice_by_file_suffix,
4
+ slice_by_pattern,
5
+ slice_by_regex,
6
+ )
7
+
8
+ __all__ = ["identity", "slice_by_file_suffix", "slice_by_pattern", "slice_by_regex"]
@@ -0,0 +1,128 @@
1
+ """Slicing projection functions that extract slice names from file paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from archunitpython.common.extraction.graph import Edge
8
+ from archunitpython.common.pattern_matching import normalize_path
9
+ from archunitpython.common.projection.types import MapFunction, MappedEdge
10
+
11
+
12
+ def identity() -> MapFunction:
13
+ """No-op mapper - uses full paths as labels."""
14
+
15
+ def mapper(edge: Edge) -> MappedEdge | None:
16
+ if edge.source == edge.target:
17
+ return None
18
+ return MappedEdge(source_label=edge.source, target_label=edge.target)
19
+
20
+ return mapper
21
+
22
+
23
+ def slice_by_pattern(pattern: str) -> MapFunction:
24
+ """Create a mapper that extracts slice names from paths using a pattern.
25
+
26
+ The pattern uses (**) as a placeholder for the slice name.
27
+ Example: 'src/(**)/' with 'src/auth/service.py' extracts 'auth'.
28
+
29
+ Args:
30
+ pattern: A pattern string containing (**) as the slice capture group.
31
+ """
32
+ # Convert the pattern to regex:
33
+ # - Escape special regex chars except (**)
34
+ # - Replace (**) with a capture group
35
+ escaped = _escape_for_regexp(pattern)
36
+ regex_str = escaped.replace(r"\(\*\*\)", "([^/]+)")
37
+ # Replace remaining ** with .* and * with [^/]*
38
+ regex_str = regex_str.replace(r"\*\*", ".*").replace(r"\*", "[^/]*")
39
+ regex = re.compile(regex_str)
40
+
41
+ def mapper(edge: Edge) -> MappedEdge | None:
42
+ if edge.external or edge.source == edge.target:
43
+ return None
44
+
45
+ source_label = _extract_slice(normalize_path(edge.source), regex)
46
+ target_label = _extract_slice(normalize_path(edge.target), regex)
47
+
48
+ if source_label is None or target_label is None:
49
+ return None
50
+ if source_label == target_label:
51
+ return None
52
+
53
+ return MappedEdge(source_label=source_label, target_label=target_label)
54
+
55
+ return mapper
56
+
57
+
58
+ def slice_by_regex(regexp: re.Pattern[str]) -> MapFunction:
59
+ """Create a mapper that extracts slice names using a regex with a capture group.
60
+
61
+ The first capture group in the regex becomes the slice name.
62
+ """
63
+
64
+ def mapper(edge: Edge) -> MappedEdge | None:
65
+ if edge.external or edge.source == edge.target:
66
+ return None
67
+
68
+ source_label = _extract_slice(normalize_path(edge.source), regexp)
69
+ target_label = _extract_slice(normalize_path(edge.target), regexp)
70
+
71
+ if source_label is None or target_label is None:
72
+ return None
73
+ if source_label == target_label:
74
+ return None
75
+
76
+ return MappedEdge(source_label=source_label, target_label=target_label)
77
+
78
+ return mapper
79
+
80
+
81
+ def slice_by_file_suffix(labeling: dict[str, str]) -> MapFunction:
82
+ """Create a mapper that assigns slices based on file name suffixes.
83
+
84
+ Args:
85
+ labeling: Mapping of suffix → slice name.
86
+ Example: {'_controller': 'controllers', '_service': 'services'}
87
+ """
88
+
89
+ def mapper(edge: Edge) -> MappedEdge | None:
90
+ if edge.external or edge.source == edge.target:
91
+ return None
92
+
93
+ source_label = _extract_suffix_label(edge.source, labeling)
94
+ target_label = _extract_suffix_label(edge.target, labeling)
95
+
96
+ if source_label is None or target_label is None:
97
+ return None
98
+ if source_label == target_label:
99
+ return None
100
+
101
+ return MappedEdge(source_label=source_label, target_label=target_label)
102
+
103
+ return mapper
104
+
105
+
106
+ def _extract_slice(path: str, regex: re.Pattern[str]) -> str | None:
107
+ """Extract slice name from path using regex first capture group."""
108
+ match = regex.search(path)
109
+ if match and match.groups():
110
+ return match.group(1)
111
+ return None
112
+
113
+
114
+ def _extract_suffix_label(path: str, labeling: dict[str, str]) -> str | None:
115
+ """Extract label by matching file suffix."""
116
+ normalized = normalize_path(path)
117
+ # Remove extension
118
+ base = normalized.rsplit(".", 1)[0] if "." in normalized else normalized
119
+
120
+ for suffix, label in labeling.items():
121
+ if base.endswith(suffix):
122
+ return label
123
+ return None
124
+
125
+
126
+ def _escape_for_regexp(pattern: str) -> str:
127
+ """Escape special regex characters in a pattern string."""
128
+ return re.escape(pattern)
@@ -0,0 +1,4 @@
1
+ from archunitpython.slices.uml.export_diagram import export_diagram
2
+ from archunitpython.slices.uml.generate_rules import Rule, generate_rule
3
+
4
+ __all__ = ["Rule", "export_diagram", "generate_rule"]
@@ -0,0 +1,31 @@
1
+ """Export a projected graph as a PlantUML diagram."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from archunitpython.common.projection.types import ProjectedEdge
6
+
7
+
8
+ def export_diagram(graph: list[ProjectedEdge]) -> str:
9
+ """Generate a PlantUML component diagram from projected edges.
10
+
11
+ Args:
12
+ graph: List of projected edges.
13
+
14
+ Returns:
15
+ PlantUML diagram as a string.
16
+ """
17
+ components: set[str] = set()
18
+ lines: list[str] = ["@startuml"]
19
+
20
+ for edge in graph:
21
+ components.add(edge.source_label)
22
+ components.add(edge.target_label)
23
+
24
+ for component in sorted(components):
25
+ lines.append(f" component [{component}]")
26
+
27
+ for edge in graph:
28
+ lines.append(f" [{edge.source_label}] --> [{edge.target_label}]")
29
+
30
+ lines.append("@enduml")
31
+ return "\n".join(lines)
@@ -0,0 +1,71 @@
1
+ """PlantUML diagram parsing and rule generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Rule:
11
+ """An allowed dependency relationship between two components."""
12
+
13
+ source: str
14
+ target: str
15
+
16
+
17
+ def generate_rule(puml_content: str) -> tuple[list[Rule], list[str]]:
18
+ """Parse a PlantUML diagram and extract component rules.
19
+
20
+ Supports component diagrams with:
21
+ - component [Name] declarations
22
+ - [Source] --> [Target] relationships
23
+ - package grouping (names extracted from components inside)
24
+
25
+ Args:
26
+ puml_content: PlantUML diagram content as a string.
27
+
28
+ Returns:
29
+ Tuple of (rules, contained_nodes):
30
+ - rules: list of allowed dependency relationships
31
+ - contained_nodes: list of all declared component names
32
+ """
33
+ rules: list[Rule] = []
34
+ contained_nodes: list[str] = []
35
+
36
+ lines = puml_content.strip().splitlines()
37
+
38
+ for line in lines:
39
+ stripped = line.strip()
40
+
41
+ # Skip empty lines, comments, @startuml/@enduml
42
+ if not stripped or stripped.startswith("@") or stripped.startswith("'"):
43
+ continue
44
+
45
+ # Match component declarations: component [Name] or component [Name] #Color
46
+ comp_match = re.match(
47
+ r"component\s+\[([^\]]+)\]", stripped
48
+ )
49
+ if comp_match:
50
+ name = comp_match.group(1).strip()
51
+ if name not in contained_nodes:
52
+ contained_nodes.append(name)
53
+ continue
54
+
55
+ # Match relationships: [Source] --> [Target] or [Source] -> [Target]
56
+ rel_match = re.match(
57
+ r"\[([^\]]+)\]\s*-+>\s*\[([^\]]+)\]", stripped
58
+ )
59
+ if rel_match:
60
+ source = rel_match.group(1).strip()
61
+ target = rel_match.group(2).strip()
62
+ rules.append(Rule(source=source, target=target))
63
+
64
+ # Ensure both nodes are in contained_nodes
65
+ if source not in contained_nodes:
66
+ contained_nodes.append(source)
67
+ if target not in contained_nodes:
68
+ contained_nodes.append(target)
69
+ continue
70
+
71
+ return rules, contained_nodes
@@ -0,0 +1,3 @@
1
+ from archunitpython.testing.assertion import assert_passes, format_violations
2
+
3
+ __all__ = ["assert_passes", "format_violations"]
@@ -0,0 +1,47 @@
1
+ """Testing assertion helpers for architecture rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from archunitpython.common.assertion.violation import Violation
6
+ from archunitpython.common.fluentapi.checkable import Checkable, CheckOptions
7
+ from archunitpython.testing.common.violation_factory import ViolationFactory
8
+
9
+
10
+ def format_violations(violations: list[Violation]) -> str:
11
+ """Format violations into a human-readable string.
12
+
13
+ Args:
14
+ violations: List of violations to format.
15
+
16
+ Returns:
17
+ Formatted string describing all violations.
18
+ """
19
+ if not violations:
20
+ return "No violations found."
21
+
22
+ lines = [f"Found {len(violations)} architecture violation(s):", ""]
23
+ for i, violation in enumerate(violations, 1):
24
+ tv = ViolationFactory.from_violation(violation)
25
+ lines.append(f" {i}. {tv.message}")
26
+ lines.append(f" {tv.details}")
27
+ lines.append("")
28
+
29
+ return "\n".join(lines)
30
+
31
+
32
+ def assert_passes(
33
+ checkable: Checkable,
34
+ options: CheckOptions | None = None,
35
+ ) -> None:
36
+ """Assert that an architecture rule passes (no violations).
37
+
38
+ Args:
39
+ checkable: Any object with a check() method (implements Checkable).
40
+ options: Optional check options.
41
+
42
+ Raises:
43
+ AssertionError: If the rule has violations.
44
+ """
45
+ violations = checkable.check(options)
46
+ if violations:
47
+ raise AssertionError(format_violations(violations))
@@ -0,0 +1,4 @@
1
+ from archunitpython.testing.common.color_utils import ColorUtils
2
+ from archunitpython.testing.common.violation_factory import TestViolation, ViolationFactory
3
+
4
+ __all__ = ["ColorUtils", "TestViolation", "ViolationFactory"]
@@ -0,0 +1,57 @@
1
+ """Terminal color utilities for formatted output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+
8
+
9
+ def _supports_color() -> bool:
10
+ """Check if the terminal supports ANSI colors."""
11
+ if os.environ.get("NO_COLOR"):
12
+ return False
13
+ if not hasattr(sys.stdout, "isatty"):
14
+ return False
15
+ return sys.stdout.isatty()
16
+
17
+
18
+ def _wrap(code: str, text: str) -> str:
19
+ if not _supports_color():
20
+ return text
21
+ return f"\033[{code}m{text}\033[0m"
22
+
23
+
24
+ class ColorUtils:
25
+ """ANSI color utilities for terminal output."""
26
+
27
+ @staticmethod
28
+ def red(text: str) -> str:
29
+ return _wrap("31", text)
30
+
31
+ @staticmethod
32
+ def green(text: str) -> str:
33
+ return _wrap("32", text)
34
+
35
+ @staticmethod
36
+ def yellow(text: str) -> str:
37
+ return _wrap("33", text)
38
+
39
+ @staticmethod
40
+ def blue(text: str) -> str:
41
+ return _wrap("34", text)
42
+
43
+ @staticmethod
44
+ def magenta(text: str) -> str:
45
+ return _wrap("35", text)
46
+
47
+ @staticmethod
48
+ def cyan(text: str) -> str:
49
+ return _wrap("36", text)
50
+
51
+ @staticmethod
52
+ def bold(text: str) -> str:
53
+ return _wrap("1", text)
54
+
55
+ @staticmethod
56
+ def dim(text: str) -> str:
57
+ return _wrap("2", text)