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,115 @@
1
+ """Utility functions for detecting Python declarations in AST."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass
10
+ class DeclarationCounts:
11
+ """Counts of different declaration types in a Python file."""
12
+
13
+ total: int = 0
14
+ protocols: int = 0
15
+ abstract_classes: int = 0
16
+ abstract_methods: int = 0
17
+ concrete_classes: int = 0
18
+ functions: int = 0
19
+ variables: int = 0
20
+
21
+
22
+ def is_abstract_class(node: ast.ClassDef) -> bool:
23
+ """Check if a class inherits from ABC or uses ABCMeta."""
24
+ for base in node.bases:
25
+ name = _get_name(base)
26
+ if name in ("ABC", "ABCMeta"):
27
+ return True
28
+ for keyword in node.keywords:
29
+ if keyword.arg == "metaclass":
30
+ name = _get_name(keyword.value)
31
+ if name == "ABCMeta":
32
+ return True
33
+ # Also detect by @abstractmethod presence
34
+ for item in node.body:
35
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
36
+ for dec in item.decorator_list:
37
+ if _get_name(dec) == "abstractmethod":
38
+ return True
39
+ return False
40
+
41
+
42
+ def is_protocol_class(node: ast.ClassDef) -> bool:
43
+ """Check if a class inherits from typing.Protocol."""
44
+ for base in node.bases:
45
+ name = _get_name(base)
46
+ if name == "Protocol":
47
+ return True
48
+ return False
49
+
50
+
51
+ def is_abstract_method(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
52
+ """Check if a method has the @abstractmethod decorator."""
53
+ for dec in node.decorator_list:
54
+ if _get_name(dec) == "abstractmethod":
55
+ return True
56
+ return False
57
+
58
+
59
+ def count_declarations(source: str) -> DeclarationCounts:
60
+ """Count all declaration types in a Python source string.
61
+
62
+ Args:
63
+ source: Python source code as a string.
64
+
65
+ Returns:
66
+ DeclarationCounts with totals for each declaration type.
67
+ """
68
+ try:
69
+ tree = ast.parse(source)
70
+ except SyntaxError:
71
+ return DeclarationCounts()
72
+
73
+ counts = DeclarationCounts()
74
+
75
+ for node in ast.walk(tree):
76
+ if isinstance(node, ast.ClassDef):
77
+ counts.total += 1
78
+ if is_protocol_class(node):
79
+ counts.protocols += 1
80
+ elif is_abstract_class(node):
81
+ counts.abstract_classes += 1
82
+ else:
83
+ counts.concrete_classes += 1
84
+
85
+ # Count abstract methods
86
+ for item in node.body:
87
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
88
+ if is_abstract_method(item):
89
+ counts.abstract_methods += 1
90
+
91
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
92
+ # Only count module-level functions
93
+ if _is_module_level(node, tree):
94
+ counts.total += 1
95
+ counts.functions += 1
96
+
97
+ elif isinstance(node, ast.Assign) and _is_module_level(node, tree):
98
+ counts.total += 1
99
+ counts.variables += 1
100
+
101
+ return counts
102
+
103
+
104
+ def _is_module_level(node: ast.AST, tree: ast.Module) -> bool:
105
+ """Check if a node is directly in the module body."""
106
+ return node in tree.body
107
+
108
+
109
+ def _get_name(node: ast.expr) -> str:
110
+ """Extract a name from an AST node."""
111
+ if isinstance(node, ast.Name):
112
+ return node.id
113
+ if isinstance(node, ast.Attribute):
114
+ return node.attr
115
+ return ""
@@ -0,0 +1,100 @@
1
+ """Structured logging for architecture checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ from archunitpython.common.logging.types import LoggingOptions
11
+
12
+ _LOG_LEVELS = {
13
+ "debug": logging.DEBUG,
14
+ "info": logging.INFO,
15
+ "warn": logging.WARNING,
16
+ "error": logging.ERROR,
17
+ }
18
+
19
+
20
+ class CheckLogger:
21
+ """Logger specialized for architecture check operations."""
22
+
23
+ def __init__(self) -> None:
24
+ self._logger = logging.getLogger("archunitpython")
25
+ self._file_handler: logging.FileHandler | None = None
26
+ self._logger.setLevel(logging.DEBUG)
27
+
28
+ def _should_log(self, options: LoggingOptions | None) -> bool:
29
+ if options is None:
30
+ return False
31
+ return options.enabled
32
+
33
+ def _ensure_file_handler(self, options: LoggingOptions) -> None:
34
+ if not options.log_file:
35
+ return
36
+ if self._file_handler is not None:
37
+ return
38
+
39
+ log_dir = Path(os.getcwd()) / "logs"
40
+ log_dir.mkdir(exist_ok=True)
41
+
42
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
43
+ log_path = log_dir / f"archunit-{timestamp}.log"
44
+
45
+ mode = "a" if options.append_to_log_file else "w"
46
+ self._file_handler = logging.FileHandler(str(log_path), mode=mode)
47
+ self._file_handler.setFormatter(
48
+ logging.Formatter("[%(levelname)s] %(message)s")
49
+ )
50
+ self._logger.addHandler(self._file_handler)
51
+
52
+ def _log(self, level: str, options: LoggingOptions | None, message: str) -> None:
53
+ if not self._should_log(options):
54
+ return
55
+ assert options is not None
56
+ log_level = _LOG_LEVELS.get(options.level, logging.INFO)
57
+ msg_level = _LOG_LEVELS.get(level, logging.INFO)
58
+ if msg_level < log_level:
59
+ return
60
+ self._ensure_file_handler(options)
61
+ self._logger.log(msg_level, message)
62
+
63
+ def debug(self, options: LoggingOptions | None, message: str) -> None:
64
+ self._log("debug", options, message)
65
+
66
+ def info(self, options: LoggingOptions | None, message: str) -> None:
67
+ self._log("info", options, message)
68
+
69
+ def warn(self, options: LoggingOptions | None, message: str) -> None:
70
+ self._log("warn", options, message)
71
+
72
+ def error(self, options: LoggingOptions | None, message: str) -> None:
73
+ self._log("error", options, message)
74
+
75
+ def start_check(self, rule_name: str, options: LoggingOptions | None) -> None:
76
+ self.info(options, f"Starting check: {rule_name}")
77
+
78
+ def end_check(
79
+ self, rule_name: str, violation_count: int, options: LoggingOptions | None
80
+ ) -> None:
81
+ self.info(
82
+ options,
83
+ f"Finished check: {rule_name} - {violation_count} violation(s)",
84
+ )
85
+
86
+ def log_violation(self, violation: object, options: LoggingOptions | None) -> None:
87
+ self.warn(options, f"Violation: {violation}")
88
+
89
+ def log_progress(self, message: str, options: LoggingOptions | None) -> None:
90
+ self.info(options, message)
91
+
92
+ def close(self) -> None:
93
+ """Remove file handler if present."""
94
+ if self._file_handler is not None:
95
+ self._logger.removeHandler(self._file_handler)
96
+ self._file_handler.close()
97
+ self._file_handler = None
98
+
99
+
100
+ shared_logger = CheckLogger()
@@ -0,0 +1,3 @@
1
+ from archunitpython.files.fluentapi.files import files, project_files
2
+
3
+ __all__ = ["files", "project_files"]
@@ -0,0 +1,28 @@
1
+ from archunitpython.files.assertion.custom_file_logic import (
2
+ CustomFileCondition,
3
+ CustomFileViolation,
4
+ FileInfo,
5
+ gather_custom_file_violations,
6
+ )
7
+ from archunitpython.files.assertion.cycle_free import ViolatingCycle, gather_cycle_violations
8
+ from archunitpython.files.assertion.depend_on_files import (
9
+ ViolatingFileDependency,
10
+ gather_depend_on_file_violations,
11
+ )
12
+ from archunitpython.files.assertion.matching_files import (
13
+ ViolatingNode,
14
+ gather_regex_matching_violations,
15
+ )
16
+
17
+ __all__ = [
18
+ "CustomFileCondition",
19
+ "CustomFileViolation",
20
+ "FileInfo",
21
+ "ViolatingCycle",
22
+ "ViolatingFileDependency",
23
+ "ViolatingNode",
24
+ "gather_custom_file_violations",
25
+ "gather_cycle_violations",
26
+ "gather_depend_on_file_violations",
27
+ "gather_regex_matching_violations",
28
+ ]
@@ -0,0 +1,107 @@
1
+ """Custom file condition logic and FileInfo type."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from typing import Callable
8
+
9
+ from archunitpython.common.assertion.violation import Violation
10
+ from archunitpython.common.pattern_matching import matches_pattern
11
+ from archunitpython.common.projection.types import ProjectedNode
12
+ from archunitpython.common.types import Filter
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class FileInfo:
17
+ """Information about a file, provided to custom conditions."""
18
+
19
+ path: str
20
+ name: str # filename without extension
21
+ extension: str
22
+ directory: str
23
+ content: str
24
+ lines_of_code: int
25
+
26
+
27
+ CustomFileCondition = Callable[[FileInfo], bool]
28
+
29
+
30
+ @dataclass
31
+ class CustomFileViolation(Violation):
32
+ """A file that violates a custom condition."""
33
+
34
+ message: str
35
+ file_info: FileInfo
36
+
37
+
38
+ def _build_file_info(file_path: str) -> FileInfo:
39
+ """Build a FileInfo from a file path."""
40
+ basename = os.path.basename(file_path)
41
+ name, ext = os.path.splitext(basename)
42
+ directory = os.path.dirname(file_path)
43
+
44
+ try:
45
+ with open(file_path, "r", encoding="utf-8", errors="replace") as f:
46
+ content = f.read()
47
+ except (OSError, IOError):
48
+ content = ""
49
+
50
+ lines = [line for line in content.splitlines() if line.strip()]
51
+
52
+ return FileInfo(
53
+ path=file_path,
54
+ name=name,
55
+ extension=ext,
56
+ directory=directory,
57
+ content=content,
58
+ lines_of_code=len(lines),
59
+ )
60
+
61
+
62
+ def gather_custom_file_violations(
63
+ nodes: list[ProjectedNode],
64
+ condition: CustomFileCondition,
65
+ message: str,
66
+ is_negated: bool,
67
+ pre_filters: list[Filter],
68
+ ) -> list[Violation]:
69
+ """Evaluate a custom condition on files matching pre_filters.
70
+
71
+ Args:
72
+ nodes: All projected nodes.
73
+ condition: Custom function that returns True if the file passes.
74
+ message: Message to include in violation.
75
+ is_negated: If False (should adhere), violation when condition returns False.
76
+ If True (shouldNot adhere), violation when condition returns True.
77
+ pre_filters: Filters to apply before checking the condition.
78
+
79
+ Returns:
80
+ List of CustomFileViolation violations.
81
+ """
82
+ violations: list[Violation] = []
83
+
84
+ for node in nodes:
85
+ # Check if node matches all pre-filters
86
+ if pre_filters and not all(
87
+ matches_pattern(node.label, f) for f in pre_filters
88
+ ):
89
+ continue
90
+
91
+ file_info = _build_file_info(node.label)
92
+ result = condition(file_info)
93
+
94
+ if is_negated:
95
+ # shouldNot: violation if condition IS True
96
+ if result:
97
+ violations.append(
98
+ CustomFileViolation(message=message, file_info=file_info)
99
+ )
100
+ else:
101
+ # should: violation if condition is NOT True
102
+ if not result:
103
+ violations.append(
104
+ CustomFileViolation(message=message, file_info=file_info)
105
+ )
106
+
107
+ return violations
@@ -0,0 +1,29 @@
1
+ """Violation gathering for cycle-free 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
+
10
+
11
+ @dataclass
12
+ class ViolatingCycle(Violation):
13
+ """A circular dependency detected between files."""
14
+
15
+ cycle: list[ProjectedEdge]
16
+
17
+
18
+ def gather_cycle_violations(
19
+ cycles: list[list[ProjectedEdge]],
20
+ ) -> list[Violation]:
21
+ """Convert detected cycles into violations.
22
+
23
+ Args:
24
+ cycles: List of cycles, each being a list of edges forming the cycle.
25
+
26
+ Returns:
27
+ List of ViolatingCycle violations.
28
+ """
29
+ return [ViolatingCycle(cycle=cycle) for cycle in cycles]
@@ -0,0 +1,67 @@
1
+ """Violation gathering for file 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 ViolatingFileDependency(Violation):
15
+ """A file dependency that violates a rule."""
16
+
17
+ dependency: ProjectedEdge
18
+ is_negated: bool = False
19
+
20
+
21
+ def gather_depend_on_file_violations(
22
+ edges: list[ProjectedEdge],
23
+ subject_filters: list[Filter],
24
+ object_filters: list[Filter],
25
+ is_negated: bool,
26
+ ) -> list[Violation]:
27
+ """Check if files have/don't have certain dependencies.
28
+
29
+ Args:
30
+ edges: Projected dependency edges.
31
+ subject_filters: Patterns for the source files (subject of the rule).
32
+ object_filters: Patterns for the target files (dependency targets).
33
+ is_negated: If False (should), files matching subject MUST depend on
34
+ files matching object.
35
+ If True (shouldNot), files matching subject must NOT
36
+ depend on files matching object.
37
+
38
+ Returns:
39
+ List of violations.
40
+ """
41
+ violations: list[Violation] = []
42
+
43
+ for edge in edges:
44
+ source_matches = all(
45
+ matches_pattern(edge.source_label, f) for f in subject_filters
46
+ )
47
+ if not source_matches:
48
+ continue
49
+
50
+ target_matches = all(
51
+ matches_pattern(edge.target_label, f) for f in object_filters
52
+ )
53
+
54
+ if is_negated:
55
+ # shouldNot: violation if dependency EXISTS
56
+ if target_matches:
57
+ violations.append(
58
+ ViolatingFileDependency(dependency=edge, is_negated=True)
59
+ )
60
+ else:
61
+ # should: violation if dependency does NOT match
62
+ if not target_matches:
63
+ violations.append(
64
+ ViolatingFileDependency(dependency=edge, is_negated=False)
65
+ )
66
+
67
+ return violations
@@ -0,0 +1,64 @@
1
+ """Violation gathering for file pattern matching 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 ProjectedNode
10
+ from archunitpython.common.types import Filter
11
+
12
+
13
+ @dataclass
14
+ class ViolatingNode(Violation):
15
+ """A file that violates a pattern matching rule."""
16
+
17
+ check_pattern: Filter
18
+ projected_node: ProjectedNode
19
+ is_negated: bool = False
20
+
21
+
22
+ def gather_regex_matching_violations(
23
+ nodes: list[ProjectedNode],
24
+ check_filters: list[Filter],
25
+ is_negated: bool,
26
+ ) -> list[Violation]:
27
+ """Check if files match/don't match given patterns.
28
+
29
+ Args:
30
+ nodes: Files to check.
31
+ check_filters: Patterns to match against.
32
+ is_negated: If False (should), files MUST match all patterns.
33
+ If True (shouldNot), files must NOT match any pattern.
34
+
35
+ Returns:
36
+ List of violations.
37
+ """
38
+ violations: list[Violation] = []
39
+
40
+ for node in nodes:
41
+ for filter_ in check_filters:
42
+ matched = matches_pattern(node.label, filter_)
43
+ if is_negated:
44
+ # shouldNot: violation if file DOES match
45
+ if matched:
46
+ violations.append(
47
+ ViolatingNode(
48
+ check_pattern=filter_,
49
+ projected_node=node,
50
+ is_negated=True,
51
+ )
52
+ )
53
+ else:
54
+ # should: violation if file does NOT match
55
+ if not matched:
56
+ violations.append(
57
+ ViolatingNode(
58
+ check_pattern=filter_,
59
+ projected_node=node,
60
+ is_negated=False,
61
+ )
62
+ )
63
+
64
+ return violations
@@ -0,0 +1,3 @@
1
+ from archunitpython.files.fluentapi.files import files, project_files
2
+
3
+ __all__ = ["files", "project_files"]