archunitpython 1.1.2__py3-none-any.whl → 1.2.1__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 (30) hide show
  1. archunitpython/__init__.py +9 -1
  2. archunitpython/common/extraction/extract_graph.py +122 -29
  3. archunitpython/common/pattern_matching.py +1 -3
  4. archunitpython/common/projection/cycles/johnsons_apsp.py +4 -9
  5. archunitpython/common/projection/cycles/tarjan_scc.py +2 -6
  6. archunitpython/common/projection/project_cycles.py +1 -2
  7. archunitpython/common/util/logger.py +1 -3
  8. archunitpython/files/assertion/custom_file_logic.py +3 -9
  9. archunitpython/files/assertion/depend_on_external_modules.py +3 -8
  10. archunitpython/files/assertion/depend_on_files.py +4 -12
  11. archunitpython/files/fluentapi/files.py +9 -27
  12. archunitpython/graph/__init__.py +35 -0
  13. archunitpython/graph/graph_reporter.py +795 -0
  14. archunitpython/layers/__init__.py +8 -0
  15. archunitpython/layers/assertion/__init__.py +9 -0
  16. archunitpython/layers/assertion/layer_dependencies.py +83 -0
  17. archunitpython/layers/fluentapi/__init__.py +6 -0
  18. archunitpython/layers/fluentapi/layers.py +116 -0
  19. archunitpython/metrics/assertion/metric_thresholds.py +1 -3
  20. archunitpython/metrics/calculation/distance.py +1 -3
  21. archunitpython/metrics/extraction/extract_class_info.py +3 -9
  22. archunitpython/metrics/fluentapi/export_utils.py +2 -6
  23. archunitpython/metrics/fluentapi/metrics.py +12 -38
  24. archunitpython/slices/fluentapi/slices.py +4 -12
  25. archunitpython/slices/uml/generate_rules.py +2 -6
  26. archunitpython/testing/common/violation_factory.py +11 -3
  27. {archunitpython-1.1.2.dist-info → archunitpython-1.2.1.dist-info}/METADATA +161 -6
  28. {archunitpython-1.1.2.dist-info → archunitpython-1.2.1.dist-info}/RECORD +30 -23
  29. {archunitpython-1.1.2.dist-info → archunitpython-1.2.1.dist-info}/WHEEL +0 -0
  30. {archunitpython-1.1.2.dist-info → archunitpython-1.2.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,8 @@
1
+ """Layer-level architecture rules."""
2
+
3
+ from archunitpython.layers.fluentapi.layers import layers, project_layers
4
+
5
+ __all__ = [
6
+ "layers",
7
+ "project_layers",
8
+ ]
@@ -0,0 +1,9 @@
1
+ from archunitpython.layers.assertion.layer_dependencies import (
2
+ LayerDependencyViolation,
3
+ gather_layer_dependency_violations,
4
+ )
5
+
6
+ __all__ = [
7
+ "LayerDependencyViolation",
8
+ "gather_layer_dependency_violations",
9
+ ]
@@ -0,0 +1,83 @@
1
+ """Violation gathering for layer dependency 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.pattern_matching import matches_pattern
9
+ from archunitpython.common.projection.types import ProjectedEdge
10
+ from archunitpython.common.types import Filter
11
+
12
+
13
+ @dataclass
14
+ class LayerDependencyViolation(Violation):
15
+ """A dependency from one named layer to a disallowed named layer."""
16
+
17
+ dependency: ProjectedEdge
18
+ source_layer: str
19
+ target_layer: str
20
+ rule: str
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class LayerDefinition:
25
+ """A named architectural layer with one or more file filters."""
26
+
27
+ name: str
28
+ filters: tuple[Filter, ...]
29
+
30
+ def matches(self, file_path: str) -> bool:
31
+ return any(matches_pattern(file_path, filter_) for filter_ in self.filters)
32
+
33
+
34
+ def gather_layer_dependency_violations(
35
+ edges: list[ProjectedEdge],
36
+ layers: list[LayerDefinition],
37
+ allowed_dependencies: dict[str, set[str]],
38
+ forbidden_dependencies: dict[str, set[str]],
39
+ ) -> list[Violation]:
40
+ """Check cross-layer dependencies against allowlist/blocklist rules."""
41
+ violations: list[Violation] = []
42
+
43
+ for edge in edges:
44
+ source_layer = _find_layer(edge.source_label, layers)
45
+ target_layer = _find_layer(edge.target_label, layers)
46
+
47
+ if source_layer is None or target_layer is None:
48
+ continue
49
+ if source_layer == target_layer:
50
+ continue
51
+
52
+ forbidden_targets = forbidden_dependencies.get(source_layer, set())
53
+ if target_layer in forbidden_targets:
54
+ violations.append(
55
+ LayerDependencyViolation(
56
+ dependency=edge,
57
+ source_layer=source_layer,
58
+ target_layer=target_layer,
59
+ rule="may_not_depend_on_layers",
60
+ )
61
+ )
62
+ continue
63
+
64
+ if source_layer in allowed_dependencies:
65
+ allowed_targets = allowed_dependencies[source_layer]
66
+ if target_layer not in allowed_targets:
67
+ violations.append(
68
+ LayerDependencyViolation(
69
+ dependency=edge,
70
+ source_layer=source_layer,
71
+ target_layer=target_layer,
72
+ rule="may_only_depend_on_layers",
73
+ )
74
+ )
75
+
76
+ return violations
77
+
78
+
79
+ def _find_layer(file_path: str, layers: list[LayerDefinition]) -> str | None:
80
+ for layer in layers:
81
+ if layer.matches(file_path):
82
+ return layer.name
83
+ return None
@@ -0,0 +1,6 @@
1
+ from archunitpython.layers.fluentapi.layers import layers, project_layers
2
+
3
+ __all__ = [
4
+ "layers",
5
+ "project_layers",
6
+ ]
@@ -0,0 +1,116 @@
1
+ """Fluent API builder chain for layer-level architecture rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from archunitpython.common.assertion.violation import Violation
6
+ from archunitpython.common.extraction.extract_graph import extract_graph
7
+ from archunitpython.common.fluentapi.checkable import CheckOptions
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.regex_factory import RegexFactory
11
+ from archunitpython.common.types import Filter, Pattern
12
+ from archunitpython.layers.assertion.layer_dependencies import (
13
+ LayerDefinition,
14
+ gather_layer_dependency_violations,
15
+ )
16
+
17
+
18
+ def project_layers(project_path: str | None = None) -> "LayeredArchitecture":
19
+ """Entry point for layer-level architecture rules."""
20
+ return LayeredArchitecture(project_path)
21
+
22
+
23
+ layers = project_layers
24
+
25
+
26
+ class LayeredArchitecture:
27
+ """Builder and checkable for named layer dependency policies."""
28
+
29
+ def __init__(self, project_path: str | None = None) -> None:
30
+ self._project_path = project_path
31
+ self._layers: dict[str, list[Filter]] = {}
32
+ self._allowed_dependencies: dict[str, set[str]] = {}
33
+ self._forbidden_dependencies: dict[str, set[str]] = {}
34
+
35
+ def layer(self, name: str) -> "LayerDefinitionBuilder":
36
+ """Start defining a named layer."""
37
+ self._layers.setdefault(name, [])
38
+ return LayerDefinitionBuilder(self, name)
39
+
40
+ def where_layer(self, name: str) -> "LayerDependencyRuleBuilder":
41
+ """Start defining dependency rules for a named layer."""
42
+ self._layers.setdefault(name, [])
43
+ return LayerDependencyRuleBuilder(self, name)
44
+
45
+ def check(self, options: CheckOptions | None = None) -> list[Violation]:
46
+ graph = extract_graph(self._project_path, options=options)
47
+ edges = project_edges(graph, per_internal_edge())
48
+ layer_definitions = [
49
+ LayerDefinition(name=name, filters=tuple(filters))
50
+ for name, filters in self._layers.items()
51
+ if filters
52
+ ]
53
+
54
+ return gather_layer_dependency_violations(
55
+ edges,
56
+ layer_definitions,
57
+ self._allowed_dependencies,
58
+ self._forbidden_dependencies,
59
+ )
60
+
61
+ def _add_layer_filter(self, name: str, filter_: Filter) -> None:
62
+ self._layers.setdefault(name, []).append(filter_)
63
+
64
+ def _allow_only(self, source_layer: str, target_layers: tuple[str, ...]) -> None:
65
+ self._allowed_dependencies[source_layer] = set(target_layers)
66
+
67
+ def _forbid(self, source_layer: str, target_layers: tuple[str, ...]) -> None:
68
+ self._forbidden_dependencies.setdefault(source_layer, set()).update(target_layers)
69
+
70
+
71
+ class LayerDefinitionBuilder:
72
+ """Configure the file patterns that make up one layer."""
73
+
74
+ def __init__(self, architecture: LayeredArchitecture, layer_name: str) -> None:
75
+ self._architecture = architecture
76
+ self._layer_name = layer_name
77
+
78
+ def defined_by(self, path: Pattern) -> LayeredArchitecture:
79
+ """Define this layer by a full path pattern."""
80
+ self._architecture._add_layer_filter(
81
+ self._layer_name,
82
+ RegexFactory.path_matcher(path),
83
+ )
84
+ return self._architecture
85
+
86
+ def defined_by_folder(self, folder: Pattern) -> LayeredArchitecture:
87
+ """Define this layer by a folder pattern."""
88
+ self._architecture._add_layer_filter(
89
+ self._layer_name,
90
+ RegexFactory.folder_matcher(folder),
91
+ )
92
+ return self._architecture
93
+
94
+
95
+ class LayerDependencyRuleBuilder:
96
+ """Configure allowed or forbidden dependencies for one layer."""
97
+
98
+ def __init__(self, architecture: LayeredArchitecture, layer_name: str) -> None:
99
+ self._architecture = architecture
100
+ self._layer_name = layer_name
101
+
102
+ def may_only_depend_on_layers(
103
+ self,
104
+ *layer_names: str,
105
+ ) -> LayeredArchitecture:
106
+ """Allow this layer to depend only on the listed other layers."""
107
+ self._architecture._allow_only(self._layer_name, layer_names)
108
+ return self._architecture
109
+
110
+ def may_not_depend_on_layers(
111
+ self,
112
+ *layer_names: str,
113
+ ) -> LayeredArchitecture:
114
+ """Forbid this layer from depending on the listed other layers."""
115
+ self._architecture._forbid(self._layer_name, layer_names)
116
+ return self._architecture
@@ -31,9 +31,7 @@ class FileCountViolation(Violation):
31
31
  comparison: MetricComparison
32
32
 
33
33
 
34
- def check_threshold(
35
- value: float, threshold: float, comparison: MetricComparison
36
- ) -> bool:
34
+ def check_threshold(value: float, threshold: float, comparison: MetricComparison) -> bool:
37
35
  """Check if a value violates a threshold.
38
36
 
39
37
  Returns True if the value is a VIOLATION.
@@ -103,8 +103,6 @@ def calculate_distance_metrics_for_project(
103
103
  average_instability=sum(m.instability for m in metrics) / len(metrics),
104
104
  average_distance=sum(m.distance for m in metrics) / len(metrics),
105
105
  files_in_zone_of_pain=sum(1 for m in metrics if m.in_zone_of_pain),
106
- files_in_zone_of_uselessness=sum(
107
- 1 for m in metrics if m.in_zone_of_uselessness
108
- ),
106
+ files_in_zone_of_uselessness=sum(1 for m in metrics if m.in_zone_of_uselessness),
109
107
  total_files=len(files),
110
108
  )
@@ -129,9 +129,7 @@ def _extract_class(node: ast.ClassDef, file_path: str) -> ClassInfo:
129
129
  for item in ast.walk(node):
130
130
  if isinstance(item, ast.Assign):
131
131
  for target in item.targets:
132
- if isinstance(target, ast.Attribute) and isinstance(
133
- target.value, ast.Name
134
- ):
132
+ if isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name):
135
133
  if target.value.id == "self":
136
134
  field_name = target.attr
137
135
  if field_name not in fields:
@@ -142,9 +140,7 @@ def _extract_class(node: ast.ClassDef, file_path: str) -> ClassInfo:
142
140
  if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
143
141
  method_name = item.name
144
142
  accessed = _find_field_accesses(item, set(fields.keys()))
145
- methods.append(
146
- MethodInfo(name=method_name, accessed_fields=accessed)
147
- )
143
+ methods.append(MethodInfo(name=method_name, accessed_fields=accessed))
148
144
  # Update field access tracking
149
145
  for field_name in accessed:
150
146
  if field_name in fields:
@@ -159,9 +155,7 @@ def _extract_class(node: ast.ClassDef, file_path: str) -> ClassInfo:
159
155
  )
160
156
 
161
157
 
162
- def _extract_enhanced_class(
163
- node: ast.ClassDef, file_path: str
164
- ) -> EnhancedClassInfo:
158
+ def _extract_enhanced_class(node: ast.ClassDef, file_path: str) -> EnhancedClassInfo:
165
159
  """Extract EnhancedClassInfo from a ClassDef AST node."""
166
160
  base = _extract_class(node, file_path)
167
161
 
@@ -35,11 +35,7 @@ class MetricsExporter:
35
35
  HTML content as a string. Also writes to file if output_path specified.
36
36
  """
37
37
  opts = options or ExportOptions()
38
- timestamp = (
39
- datetime.now().strftime("%Y-%m-%d %H:%M:%S")
40
- if opts.include_timestamp
41
- else ""
42
- )
38
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") if opts.include_timestamp else ""
43
39
 
44
40
  css = opts.custom_css or _DEFAULT_CSS
45
41
 
@@ -55,7 +51,7 @@ class MetricsExporter:
55
51
  </head>
56
52
  <body>
57
53
  <h1>{opts.title}</h1>
58
- {f'<p class="timestamp">Generated: {timestamp}</p>' if timestamp else ''}
54
+ {f'<p class="timestamp">Generated: {timestamp}</p>' if timestamp else ""}
59
55
  <table>
60
56
  <thead>
61
57
  <tr><th>Metric</th><th>Value</th></tr>
@@ -97,9 +97,7 @@ class MetricsBuilder:
97
97
  )
98
98
 
99
99
 
100
- def _get_filtered_classes(
101
- project_path: str | None, filters: list[Filter]
102
- ) -> list[ClassInfo]:
100
+ def _get_filtered_classes(project_path: str | None, filters: list[Filter]) -> list[ClassInfo]:
103
101
  classes = extract_class_info(project_path)
104
102
  if not filters:
105
103
  return classes
@@ -119,39 +117,25 @@ class CountMetricsBuilder:
119
117
  self._filters = filters
120
118
 
121
119
  def method_count(self) -> "ClassMetricThresholdBuilder":
122
- return ClassMetricThresholdBuilder(
123
- self._project_path, self._filters, MethodCountMetric()
124
- )
120
+ return ClassMetricThresholdBuilder(self._project_path, self._filters, MethodCountMetric())
125
121
 
126
122
  def field_count(self) -> "ClassMetricThresholdBuilder":
127
- return ClassMetricThresholdBuilder(
128
- self._project_path, self._filters, FieldCountMetric()
129
- )
123
+ return ClassMetricThresholdBuilder(self._project_path, self._filters, FieldCountMetric())
130
124
 
131
125
  def lines_of_code(self) -> "FileMetricThresholdBuilder":
132
- return FileMetricThresholdBuilder(
133
- self._project_path, self._filters, LinesOfCodeMetric()
134
- )
126
+ return FileMetricThresholdBuilder(self._project_path, self._filters, LinesOfCodeMetric())
135
127
 
136
128
  def statements(self) -> "FileMetricThresholdBuilder":
137
- return FileMetricThresholdBuilder(
138
- self._project_path, self._filters, StatementCountMetric()
139
- )
129
+ return FileMetricThresholdBuilder(self._project_path, self._filters, StatementCountMetric())
140
130
 
141
131
  def imports(self) -> "FileMetricThresholdBuilder":
142
- return FileMetricThresholdBuilder(
143
- self._project_path, self._filters, ImportCountMetric()
144
- )
132
+ return FileMetricThresholdBuilder(self._project_path, self._filters, ImportCountMetric())
145
133
 
146
134
  def classes(self) -> "FileMetricThresholdBuilder":
147
- return FileMetricThresholdBuilder(
148
- self._project_path, self._filters, ClassCountMetric()
149
- )
135
+ return FileMetricThresholdBuilder(self._project_path, self._filters, ClassCountMetric())
150
136
 
151
137
  def functions(self) -> "FileMetricThresholdBuilder":
152
- return FileMetricThresholdBuilder(
153
- self._project_path, self._filters, FunctionCountMetric()
154
- )
138
+ return FileMetricThresholdBuilder(self._project_path, self._filters, FunctionCountMetric())
155
139
 
156
140
 
157
141
  class ClassMetricThresholdBuilder:
@@ -340,19 +324,13 @@ class DistanceMetricsBuilder:
340
324
  self._filters = filters
341
325
 
342
326
  def abstractness(self) -> "DistanceThresholdBuilder":
343
- return DistanceThresholdBuilder(
344
- self._project_path, self._filters, "abstractness"
345
- )
327
+ return DistanceThresholdBuilder(self._project_path, self._filters, "abstractness")
346
328
 
347
329
  def instability(self) -> "DistanceThresholdBuilder":
348
- return DistanceThresholdBuilder(
349
- self._project_path, self._filters, "instability"
350
- )
330
+ return DistanceThresholdBuilder(self._project_path, self._filters, "instability")
351
331
 
352
332
  def distance_from_main_sequence(self) -> "DistanceThresholdBuilder":
353
- return DistanceThresholdBuilder(
354
- self._project_path, self._filters, "distance"
355
- )
333
+ return DistanceThresholdBuilder(self._project_path, self._filters, "distance")
356
334
 
357
335
  def not_in_zone_of_pain(self) -> "ZoneCondition":
358
336
  return ZoneCondition(self._project_path, self._filters, "pain")
@@ -440,11 +418,7 @@ class ZoneCondition:
440
418
 
441
419
  for file_result in files:
442
420
  dm = calculate_file_distance_metrics(file_result, files)
443
- in_zone = (
444
- dm.in_zone_of_pain
445
- if self._zone_type == "pain"
446
- else dm.in_zone_of_uselessness
447
- )
421
+ in_zone = dm.in_zone_of_pain if self._zone_type == "pain" else dm.in_zone_of_uselessness
448
422
 
449
423
  if in_zone:
450
424
  violations.append(
@@ -61,15 +61,11 @@ class SliceConditionBuilder:
61
61
 
62
62
  def should(self) -> "PositiveConditionBuilder":
63
63
  """Begin positive assertion (slices SHOULD ...)."""
64
- return PositiveConditionBuilder(
65
- self._project_path, self._pattern, self._regex
66
- )
64
+ return PositiveConditionBuilder(self._project_path, self._pattern, self._regex)
67
65
 
68
66
  def should_not(self) -> "NegativeConditionBuilder":
69
67
  """Begin negative assertion (slices SHOULD NOT ...)."""
70
- return NegativeConditionBuilder(
71
- self._project_path, self._pattern, self._regex
72
- )
68
+ return NegativeConditionBuilder(self._project_path, self._pattern, self._regex)
73
69
 
74
70
 
75
71
  class PositiveConditionBuilder:
@@ -133,9 +129,7 @@ class NegativeConditionBuilder:
133
129
  self._regex = regex
134
130
  self._forbidden_deps: list[tuple[str, str]] = []
135
131
 
136
- def contain_dependency(
137
- self, source: str, target: str
138
- ) -> "NegativeSliceCondition":
132
+ def contain_dependency(self, source: str, target: str) -> "NegativeSliceCondition":
139
133
  """Assert that a specific dependency should NOT exist."""
140
134
  return NegativeSliceCondition(
141
135
  self._project_path,
@@ -170,9 +164,7 @@ class PositiveSliceCondition:
170
164
  mapper = self._get_mapper()
171
165
  edges = project_edges(graph, mapper)
172
166
 
173
- return gather_positive_violations(
174
- edges, rules, contained_nodes, self._coherence_options
175
- )
167
+ return gather_positive_violations(edges, rules, contained_nodes, self._coherence_options)
176
168
 
177
169
  def _get_mapper(self) -> MapFunction:
178
170
  if self._pattern:
@@ -43,9 +43,7 @@ def generate_rule(puml_content: str) -> tuple[list[Rule], list[str]]:
43
43
  continue
44
44
 
45
45
  # Match component declarations: component [Name] or component [Name] #Color
46
- comp_match = re.match(
47
- r"component\s+\[([^\]]+)\]", stripped
48
- )
46
+ comp_match = re.match(r"component\s+\[([^\]]+)\]", stripped)
49
47
  if comp_match:
50
48
  name = comp_match.group(1).strip()
51
49
  if name not in contained_nodes:
@@ -53,9 +51,7 @@ def generate_rule(puml_content: str) -> tuple[list[Rule], list[str]]:
53
51
  continue
54
52
 
55
53
  # Match relationships: [Source] --> [Target] or [Source] -> [Target]
56
- rel_match = re.match(
57
- r"\[([^\]]+)\]\s*-+>\s*\[([^\]]+)\]", stripped
58
- )
54
+ rel_match = re.match(r"\[([^\]]+)\]\s*-+>\s*\[([^\]]+)\]", stripped)
59
55
  if rel_match:
60
56
  source = rel_match.group(1).strip()
61
57
  target = rel_match.group(2).strip()
@@ -12,6 +12,7 @@ from archunitpython.files.assertion.depend_on_external_modules import (
12
12
  )
13
13
  from archunitpython.files.assertion.depend_on_files import ViolatingFileDependency
14
14
  from archunitpython.files.assertion.matching_files import ViolatingNode
15
+ from archunitpython.layers.assertion.layer_dependencies import LayerDependencyViolation
15
16
  from archunitpython.metrics.assertion.metric_thresholds import (
16
17
  FileCountViolation,
17
18
  MetricViolation,
@@ -65,9 +66,7 @@ class ViolationFactory:
65
66
  )
66
67
 
67
68
  if isinstance(violation, ViolatingCycle):
68
- cycle_str = " -> ".join(
69
- e.source_label for e in violation.cycle
70
- )
69
+ cycle_str = " -> ".join(e.source_label for e in violation.cycle)
71
70
  return TestViolation(
72
71
  message="Circular dependency detected",
73
72
  details=f"Cycle: {cycle_str}",
@@ -79,6 +78,15 @@ class ViolationFactory:
79
78
  details=f"File: {violation.file_info.path}",
80
79
  )
81
80
 
81
+ if isinstance(violation, LayerDependencyViolation):
82
+ edge = violation.dependency
83
+ return TestViolation(
84
+ message="Layer dependency violation",
85
+ details=f"Layer '{violation.source_layer}' depends on "
86
+ f"disallowed layer '{violation.target_layer}' via "
87
+ f"'{edge.source_label}' -> '{edge.target_label}'",
88
+ )
89
+
82
90
  if isinstance(violation, MetricViolation):
83
91
  return TestViolation(
84
92
  message=f"Metric '{violation.metric_name}' violation",