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