archunitpython 1.4.0__py3-none-any.whl → 1.6.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 +5 -1
- archunitpython/common/extraction/extract_graph.py +152 -20
- archunitpython/common/extraction/graph.py +1 -0
- archunitpython/config/__init__.py +5 -0
- archunitpython/config/loader.py +119 -0
- archunitpython/files/assertion/custom_file_logic.py +3 -3
- archunitpython/files/assertion/depend_on_files.py +3 -3
- archunitpython/files/assertion/matching_files.py +3 -3
- archunitpython/slices/assertion/admissible_edges.py +1 -1
- {archunitpython-1.4.0.dist-info → archunitpython-1.6.0.dist-info}/METADATA +117 -12
- {archunitpython-1.4.0.dist-info → archunitpython-1.6.0.dist-info}/RECORD +13 -11
- {archunitpython-1.4.0.dist-info → archunitpython-1.6.0.dist-info}/WHEEL +1 -1
- {archunitpython-1.4.0.dist-info → archunitpython-1.6.0.dist-info}/licenses/LICENSE +0 -0
archunitpython/__init__.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""ArchUnitPython - Architecture testing library for Python projects."""
|
|
2
2
|
|
|
3
|
-
__version__ = "1.
|
|
3
|
+
__version__ = "1.6.0"
|
|
4
4
|
|
|
5
5
|
# Files API
|
|
6
6
|
# Common
|
|
@@ -12,6 +12,7 @@ from archunitpython.common import (
|
|
|
12
12
|
Violation,
|
|
13
13
|
)
|
|
14
14
|
from archunitpython.common.extraction import clear_graph_cache, extract_graph
|
|
15
|
+
from archunitpython.config import ConfiguredRule, rules_from_config
|
|
15
16
|
from archunitpython.files import files, project_files
|
|
16
17
|
from archunitpython.graph import dependency_graph, project_graph
|
|
17
18
|
from archunitpython.layers import layers, project_layers
|
|
@@ -35,6 +36,9 @@ __all__ = [
|
|
|
35
36
|
# Layers
|
|
36
37
|
"project_layers",
|
|
37
38
|
"layers",
|
|
39
|
+
# Config
|
|
40
|
+
"rules_from_config",
|
|
41
|
+
"ConfiguredRule",
|
|
38
42
|
# Slices
|
|
39
43
|
"project_slices",
|
|
40
44
|
# Metrics
|
|
@@ -43,6 +43,8 @@ class _LocatedImport:
|
|
|
43
43
|
module_name: str
|
|
44
44
|
import_kind: ImportKind
|
|
45
45
|
line_number: int
|
|
46
|
+
resolution_kind: ImportKind | None = None
|
|
47
|
+
fallback_module_name: str | None = None
|
|
46
48
|
|
|
47
49
|
|
|
48
50
|
@dataclass(frozen=True)
|
|
@@ -182,9 +184,19 @@ def _extract_graph_uncached(
|
|
|
182
184
|
and import_kind == ImportKind.TYPE_IMPORT
|
|
183
185
|
):
|
|
184
186
|
continue
|
|
187
|
+
resolution_kind = located_import.resolution_kind or import_kind
|
|
185
188
|
resolved, is_external = _resolve_import(
|
|
186
|
-
module_name, file_path, project_path,
|
|
189
|
+
module_name, file_path, project_path, resolution_kind
|
|
187
190
|
)
|
|
191
|
+
if is_external and located_import.fallback_module_name is not None:
|
|
192
|
+
fallback, fallback_is_external = _resolve_import(
|
|
193
|
+
located_import.fallback_module_name,
|
|
194
|
+
file_path,
|
|
195
|
+
project_path,
|
|
196
|
+
resolution_kind,
|
|
197
|
+
)
|
|
198
|
+
if fallback and not fallback_is_external:
|
|
199
|
+
resolved, is_external = fallback, False
|
|
188
200
|
if resolved and resolved != _normalize(file_path):
|
|
189
201
|
# Check if the resolved path is in our project
|
|
190
202
|
if not is_external and resolved not in normalized_py_file_set:
|
|
@@ -195,7 +207,7 @@ def _extract_graph_uncached(
|
|
|
195
207
|
source=_normalize(file_path),
|
|
196
208
|
target=resolved,
|
|
197
209
|
external=is_external,
|
|
198
|
-
import_kinds=(
|
|
210
|
+
import_kinds=_edge_import_kinds(located_import),
|
|
199
211
|
)
|
|
200
212
|
)
|
|
201
213
|
|
|
@@ -299,32 +311,60 @@ def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
|
|
|
299
311
|
imports: list[_LocatedImport] = []
|
|
300
312
|
ignore_directives = _find_ignore_directives(source)
|
|
301
313
|
type_checking_ranges = _find_type_checking_ranges(tree)
|
|
314
|
+
conditional_import_ranges = _find_conditional_import_ranges(tree)
|
|
302
315
|
|
|
303
316
|
for node in ast.walk(tree):
|
|
304
317
|
if isinstance(node, ast.Import):
|
|
305
|
-
|
|
306
|
-
kind =
|
|
318
|
+
syntax_kind = ImportKind.IMPORT
|
|
319
|
+
kind = _classify_import(
|
|
320
|
+
node,
|
|
321
|
+
syntax_kind,
|
|
322
|
+
type_checking_ranges,
|
|
323
|
+
conditional_import_ranges,
|
|
324
|
+
)
|
|
307
325
|
for alias in node.names:
|
|
308
|
-
imports.append(
|
|
326
|
+
imports.append(
|
|
327
|
+
_LocatedImport(alias.name, kind, node.lineno, syntax_kind)
|
|
328
|
+
)
|
|
309
329
|
|
|
310
330
|
elif isinstance(node, ast.ImportFrom):
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
331
|
+
syntax_kind = (
|
|
332
|
+
ImportKind.RELATIVE_IMPORT
|
|
333
|
+
if node.level and node.level > 0
|
|
334
|
+
else ImportKind.FROM_IMPORT
|
|
335
|
+
)
|
|
336
|
+
kind = _classify_import(
|
|
337
|
+
node,
|
|
338
|
+
syntax_kind,
|
|
339
|
+
type_checking_ranges,
|
|
340
|
+
conditional_import_ranges,
|
|
341
|
+
)
|
|
342
|
+
fallback_module_name = (
|
|
343
|
+
"." * node.level if node.level and node.module is None else None
|
|
344
|
+
)
|
|
345
|
+
for module_name in _import_from_module_names(node):
|
|
346
|
+
imports.append(
|
|
347
|
+
_LocatedImport(
|
|
348
|
+
module_name,
|
|
349
|
+
kind,
|
|
350
|
+
node.lineno,
|
|
351
|
+
syntax_kind,
|
|
352
|
+
fallback_module_name,
|
|
353
|
+
)
|
|
354
|
+
)
|
|
322
355
|
|
|
323
356
|
elif isinstance(node, ast.Call):
|
|
324
|
-
|
|
325
|
-
kind =
|
|
357
|
+
syntax_kind = ImportKind.DYNAMIC_IMPORT
|
|
358
|
+
kind = _classify_import(
|
|
359
|
+
node,
|
|
360
|
+
syntax_kind,
|
|
361
|
+
type_checking_ranges,
|
|
362
|
+
conditional_import_ranges,
|
|
363
|
+
)
|
|
326
364
|
for module_name in _extract_dynamic_import_names(node):
|
|
327
|
-
imports.append(
|
|
365
|
+
imports.append(
|
|
366
|
+
_LocatedImport(module_name, kind, node.lineno, syntax_kind)
|
|
367
|
+
)
|
|
328
368
|
|
|
329
369
|
return [
|
|
330
370
|
import_
|
|
@@ -389,6 +429,43 @@ def _extract_dynamic_import_names(node: ast.Call) -> list[str]:
|
|
|
389
429
|
return []
|
|
390
430
|
|
|
391
431
|
|
|
432
|
+
def _import_from_module_names(node: ast.ImportFrom) -> tuple[str, ...]:
|
|
433
|
+
"""Return resolvable module names for a from-import statement."""
|
|
434
|
+
dots = "." * (node.level or 0)
|
|
435
|
+
if node.module:
|
|
436
|
+
return (f"{dots}{node.module}",)
|
|
437
|
+
|
|
438
|
+
aliases = tuple(alias.name for alias in node.names if alias.name != "*")
|
|
439
|
+
if dots and aliases:
|
|
440
|
+
return tuple(f"{dots}{alias}" for alias in aliases)
|
|
441
|
+
return (dots,) if dots else ()
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _edge_import_kinds(import_: _LocatedImport) -> tuple[ImportKind, ...]:
|
|
445
|
+
"""Return graph labels without losing syntax for conditional imports."""
|
|
446
|
+
resolution_kind = import_.resolution_kind or import_.import_kind
|
|
447
|
+
if (
|
|
448
|
+
import_.import_kind == ImportKind.CONDITIONAL_IMPORT
|
|
449
|
+
and resolution_kind != import_.import_kind
|
|
450
|
+
):
|
|
451
|
+
return (resolution_kind, import_.import_kind)
|
|
452
|
+
return (import_.import_kind,)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _classify_import(
|
|
456
|
+
node: ast.AST,
|
|
457
|
+
default_kind: ImportKind,
|
|
458
|
+
type_checking_ranges: list[tuple[int, int]],
|
|
459
|
+
conditional_import_ranges: list[tuple[int, int]],
|
|
460
|
+
) -> ImportKind:
|
|
461
|
+
"""Classify an import node by special context before syntax kind."""
|
|
462
|
+
if _in_type_checking(node, type_checking_ranges):
|
|
463
|
+
return ImportKind.TYPE_IMPORT
|
|
464
|
+
if _in_conditional_import(node, conditional_import_ranges):
|
|
465
|
+
return ImportKind.CONDITIONAL_IMPORT
|
|
466
|
+
return default_kind
|
|
467
|
+
|
|
468
|
+
|
|
392
469
|
def _find_type_checking_ranges(tree: ast.Module) -> list[tuple[int, int]]:
|
|
393
470
|
"""Find line ranges of TYPE_CHECKING blocks."""
|
|
394
471
|
ranges: list[tuple[int, int]] = []
|
|
@@ -414,6 +491,46 @@ def _find_type_checking_ranges(tree: ast.Module) -> list[tuple[int, int]]:
|
|
|
414
491
|
return sorted(ranges, key=lambda ele: ele[0])
|
|
415
492
|
|
|
416
493
|
|
|
494
|
+
def _find_conditional_import_ranges(tree: ast.Module) -> list[tuple[int, int]]:
|
|
495
|
+
"""Find try/except ImportError ranges that contain optional imports."""
|
|
496
|
+
ranges: list[tuple[int, int]] = []
|
|
497
|
+
|
|
498
|
+
for node in ast.walk(tree):
|
|
499
|
+
if not isinstance(node, ast.Try):
|
|
500
|
+
continue
|
|
501
|
+
if not any(_handles_import_error(handler.type) for handler in node.handlers):
|
|
502
|
+
continue
|
|
503
|
+
|
|
504
|
+
ranges.extend(_statement_ranges(node.body))
|
|
505
|
+
for handler in node.handlers:
|
|
506
|
+
if _handles_import_error(handler.type):
|
|
507
|
+
ranges.extend(_statement_ranges(handler.body))
|
|
508
|
+
|
|
509
|
+
return sorted(ranges, key=lambda ele: ele[0])
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _handles_import_error(node: ast.expr | None) -> bool:
|
|
513
|
+
"""Return True if an except handler catches import-related errors."""
|
|
514
|
+
if node is None:
|
|
515
|
+
return False
|
|
516
|
+
if isinstance(node, ast.Name):
|
|
517
|
+
return node.id in {"ImportError", "ModuleNotFoundError"}
|
|
518
|
+
if isinstance(node, ast.Attribute):
|
|
519
|
+
return node.attr in {"ImportError", "ModuleNotFoundError"}
|
|
520
|
+
if isinstance(node, ast.Tuple):
|
|
521
|
+
return any(_handles_import_error(elt) for elt in node.elts)
|
|
522
|
+
return False
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _statement_ranges(statements: list[ast.stmt]) -> list[tuple[int, int]]:
|
|
526
|
+
"""Return line ranges covered by statement blocks."""
|
|
527
|
+
if not statements:
|
|
528
|
+
return []
|
|
529
|
+
start = statements[0].lineno
|
|
530
|
+
end = max(getattr(statement, "end_lineno", statement.lineno) for statement in statements)
|
|
531
|
+
return [(start, end)]
|
|
532
|
+
|
|
533
|
+
|
|
417
534
|
def _in_type_checking(node: ast.AST, ranges: list[tuple[int, int]]) -> bool:
|
|
418
535
|
"""Check if a node is inside a TYPE_CHECKING block."""
|
|
419
536
|
if not hasattr(node, "lineno"):
|
|
@@ -422,6 +539,14 @@ def _in_type_checking(node: ast.AST, ranges: list[tuple[int, int]]) -> bool:
|
|
|
422
539
|
return any(start <= lineno <= end for start, end in ranges)
|
|
423
540
|
|
|
424
541
|
|
|
542
|
+
def _in_conditional_import(node: ast.AST, ranges: list[tuple[int, int]]) -> bool:
|
|
543
|
+
"""Check if a node is inside a try/except ImportError block."""
|
|
544
|
+
if not hasattr(node, "lineno"):
|
|
545
|
+
return False
|
|
546
|
+
lineno = node.lineno
|
|
547
|
+
return any(start <= lineno <= end for start, end in ranges)
|
|
548
|
+
|
|
549
|
+
|
|
425
550
|
def _resolve_import(
|
|
426
551
|
import_name: str,
|
|
427
552
|
source_file: str,
|
|
@@ -433,7 +558,14 @@ def _resolve_import(
|
|
|
433
558
|
Returns (resolved_path, is_external).
|
|
434
559
|
The path is normalized with forward slashes.
|
|
435
560
|
"""
|
|
436
|
-
if
|
|
561
|
+
if (
|
|
562
|
+
kind
|
|
563
|
+
in (
|
|
564
|
+
ImportKind.RELATIVE_IMPORT,
|
|
565
|
+
ImportKind.TYPE_IMPORT,
|
|
566
|
+
)
|
|
567
|
+
and import_name.startswith(".")
|
|
568
|
+
):
|
|
437
569
|
# Relative import
|
|
438
570
|
return _resolve_relative_import(import_name, source_file, project_root)
|
|
439
571
|
|
|
@@ -14,6 +14,7 @@ class ImportKind(Enum):
|
|
|
14
14
|
RELATIVE_IMPORT = "relative" # from . import bar / from ..foo import bar
|
|
15
15
|
DYNAMIC_IMPORT = "dynamic" # __import__('foo') / importlib.import_module()
|
|
16
16
|
TYPE_IMPORT = "type" # inside TYPE_CHECKING block
|
|
17
|
+
CONDITIONAL_IMPORT = "conditional" # inside try/except ImportError
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
@dataclass(frozen=True)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Load common architecture rules from a JSON configuration file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from archunitpython.common.assertion.violation import Violation
|
|
12
|
+
from archunitpython.common.error.errors import UserError
|
|
13
|
+
from archunitpython.common.fluentapi.checkable import Checkable, CheckOptions
|
|
14
|
+
from archunitpython.files.fluentapi.files import project_files
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class ConfiguredRule:
|
|
19
|
+
"""A named rule loaded from a configuration file."""
|
|
20
|
+
|
|
21
|
+
name: str
|
|
22
|
+
rule: Checkable
|
|
23
|
+
|
|
24
|
+
def check(self, options: CheckOptions | None = None) -> list[Violation]:
|
|
25
|
+
"""Run the configured rule."""
|
|
26
|
+
return self.rule.check(options)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def rules_from_config(config_path: str) -> list[ConfiguredRule]:
|
|
30
|
+
"""Load common architecture rules from a JSON config file.
|
|
31
|
+
|
|
32
|
+
The fluent Python API remains the primary interface. Config files provide a
|
|
33
|
+
lightweight way to share straightforward rules across projects or teams.
|
|
34
|
+
"""
|
|
35
|
+
path = Path(config_path)
|
|
36
|
+
try:
|
|
37
|
+
raw_config = json.loads(path.read_text(encoding="utf-8"))
|
|
38
|
+
except OSError as exc:
|
|
39
|
+
raise UserError(f"Could not read config file: {config_path}") from exc
|
|
40
|
+
except json.JSONDecodeError as exc:
|
|
41
|
+
raise UserError(f"Invalid JSON config file: {config_path}") from exc
|
|
42
|
+
|
|
43
|
+
if not isinstance(raw_config, dict):
|
|
44
|
+
raise UserError("Architecture config must be a JSON object.")
|
|
45
|
+
|
|
46
|
+
project_path = _optional_string(raw_config, "project_path") or os.getcwd()
|
|
47
|
+
rules = raw_config.get("rules")
|
|
48
|
+
if not isinstance(rules, list):
|
|
49
|
+
raise UserError("Architecture config must define a 'rules' list.")
|
|
50
|
+
|
|
51
|
+
base_dir = str(path.parent if path.parent != Path("") else Path.cwd())
|
|
52
|
+
resolved_project_path = _resolve_project_path(base_dir, project_path)
|
|
53
|
+
|
|
54
|
+
return [_build_rule(resolved_project_path, item, index) for index, item in enumerate(rules, 1)]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _build_rule(project_path: str, item: Any, index: int) -> ConfiguredRule:
|
|
58
|
+
if not isinstance(item, dict):
|
|
59
|
+
raise UserError(f"Rule #{index} must be a JSON object.")
|
|
60
|
+
|
|
61
|
+
rule_type = _required_string(item, "type", index)
|
|
62
|
+
name = _optional_string(item, "name") or f"{rule_type} rule #{index}"
|
|
63
|
+
rule: Checkable
|
|
64
|
+
if rule_type == "no_cycles":
|
|
65
|
+
subject = _optional_string(item, "subject")
|
|
66
|
+
builder = project_files(project_path)
|
|
67
|
+
if subject is not None:
|
|
68
|
+
rule = builder.in_path(subject).should().have_no_cycles()
|
|
69
|
+
else:
|
|
70
|
+
rule = builder.should().have_no_cycles()
|
|
71
|
+
elif rule_type == "forbidden_dependency":
|
|
72
|
+
source = _required_string(item, "source", index)
|
|
73
|
+
target = _required_string(item, "target", index)
|
|
74
|
+
rule = (
|
|
75
|
+
project_files(project_path)
|
|
76
|
+
.in_path(source)
|
|
77
|
+
.should_not()
|
|
78
|
+
.depend_on_files()
|
|
79
|
+
.in_path(target)
|
|
80
|
+
)
|
|
81
|
+
elif rule_type == "forbidden_external_dependency":
|
|
82
|
+
source = _required_string(item, "source", index)
|
|
83
|
+
module = _required_string(item, "module", index)
|
|
84
|
+
rule = (
|
|
85
|
+
project_files(project_path)
|
|
86
|
+
.in_path(source)
|
|
87
|
+
.should_not()
|
|
88
|
+
.depend_on_external_modules()
|
|
89
|
+
.matching(module)
|
|
90
|
+
)
|
|
91
|
+
else:
|
|
92
|
+
raise UserError(
|
|
93
|
+
f"Unsupported rule type '{rule_type}'. Supported types: "
|
|
94
|
+
"no_cycles, forbidden_dependency, forbidden_external_dependency."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return ConfiguredRule(name=name, rule=rule)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _resolve_project_path(base_dir: str, project_path: str) -> str:
|
|
101
|
+
if os.path.isabs(project_path):
|
|
102
|
+
return project_path
|
|
103
|
+
return os.path.abspath(os.path.join(base_dir, project_path))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _required_string(rule: dict[str, Any], key: str, index: int) -> str:
|
|
107
|
+
value = rule.get(key)
|
|
108
|
+
if not isinstance(value, str) or not value.strip():
|
|
109
|
+
raise UserError(f"Rule #{index} must define a non-empty string '{key}'.")
|
|
110
|
+
return value
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _optional_string(rule: dict[str, Any], key: str) -> str | None:
|
|
114
|
+
value = rule.get(key)
|
|
115
|
+
if value is None:
|
|
116
|
+
return None
|
|
117
|
+
if not isinstance(value, str) or not value.strip():
|
|
118
|
+
raise UserError(f"Config value '{key}' must be a non-empty string.")
|
|
119
|
+
return value
|
|
@@ -72,8 +72,8 @@ def gather_custom_file_violations(
|
|
|
72
72
|
nodes: All projected nodes.
|
|
73
73
|
condition: Custom function that returns True if the file passes.
|
|
74
74
|
message: Message to include in violation.
|
|
75
|
-
is_negated: If False (
|
|
76
|
-
If True (
|
|
75
|
+
is_negated: If False (positive assertion), violation when condition returns False.
|
|
76
|
+
If True (negated assertion), violation when condition returns True.
|
|
77
77
|
pre_filters: Filters to apply before checking the condition.
|
|
78
78
|
|
|
79
79
|
Returns:
|
|
@@ -90,7 +90,7 @@ def gather_custom_file_violations(
|
|
|
90
90
|
result = condition(file_info)
|
|
91
91
|
|
|
92
92
|
if is_negated:
|
|
93
|
-
#
|
|
93
|
+
# Negated assertion: violation if condition IS True
|
|
94
94
|
if result:
|
|
95
95
|
violations.append(CustomFileViolation(message=message, file_info=file_info))
|
|
96
96
|
else:
|
|
@@ -30,9 +30,9 @@ def gather_depend_on_file_violations(
|
|
|
30
30
|
edges: Projected dependency edges.
|
|
31
31
|
subject_filters: Patterns for the source files (subject of the rule).
|
|
32
32
|
object_filters: Patterns for the target files (dependency targets).
|
|
33
|
-
is_negated: If False (should), files matching subject MUST depend on
|
|
33
|
+
is_negated: If False (`should()`), files matching subject MUST depend on
|
|
34
34
|
files matching object.
|
|
35
|
-
If True (
|
|
35
|
+
If True (`should_not()`), files matching subject must NOT
|
|
36
36
|
depend on files matching object.
|
|
37
37
|
|
|
38
38
|
Returns:
|
|
@@ -48,7 +48,7 @@ def gather_depend_on_file_violations(
|
|
|
48
48
|
target_matches = all(matches_pattern(edge.target_label, f) for f in object_filters)
|
|
49
49
|
|
|
50
50
|
if is_negated:
|
|
51
|
-
#
|
|
51
|
+
# should_not(): violation if dependency EXISTS
|
|
52
52
|
if target_matches:
|
|
53
53
|
violations.append(ViolatingFileDependency(dependency=edge, is_negated=True))
|
|
54
54
|
else:
|
|
@@ -29,8 +29,8 @@ def gather_regex_matching_violations(
|
|
|
29
29
|
Args:
|
|
30
30
|
nodes: Files to check.
|
|
31
31
|
check_filters: Patterns to match against.
|
|
32
|
-
is_negated: If False (should), files MUST match all patterns.
|
|
33
|
-
If True (
|
|
32
|
+
is_negated: If False (`should()`), files MUST match all patterns.
|
|
33
|
+
If True (`should_not()`), files must NOT match any pattern.
|
|
34
34
|
|
|
35
35
|
Returns:
|
|
36
36
|
List of violations.
|
|
@@ -41,7 +41,7 @@ def gather_regex_matching_violations(
|
|
|
41
41
|
for filter_ in check_filters:
|
|
42
42
|
matched = matches_pattern(node.label, filter_)
|
|
43
43
|
if is_negated:
|
|
44
|
-
#
|
|
44
|
+
# should_not(): violation if file DOES match
|
|
45
45
|
if matched:
|
|
46
46
|
violations.append(
|
|
47
47
|
ViolatingNode(
|
|
@@ -30,7 +30,7 @@ def gather_violations(
|
|
|
30
30
|
edges: list[ProjectedEdge],
|
|
31
31
|
rules: list[Rule],
|
|
32
32
|
) -> list[Violation]:
|
|
33
|
-
"""Check for forbidden dependencies (used with
|
|
33
|
+
"""Check for forbidden dependencies (used with `should_not()`).
|
|
34
34
|
|
|
35
35
|
Args:
|
|
36
36
|
edges: Projected dependency edges between slices.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: archunitpython
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.6.0
|
|
4
4
|
Summary: Architecture testing library for Python projects. Enforce dependency rules, detect cycles, validate metrics.
|
|
5
5
|
Project-URL: Homepage, https://github.com/LukasNiessen/ArchUnitPython
|
|
6
6
|
Project-URL: Repository, https://github.com/LukasNiessen/ArchUnitPython.git
|
|
@@ -41,7 +41,6 @@ Description-Content-Type: text/markdown
|
|
|
41
41
|
|
|
42
42
|
[](https://opensource.org/licenses/MIT) [](https://github.com/LukasNiessen/ArchUnitPython/actions/workflows/integrate.yaml) [](https://github.com/LukasNiessen/ArchUnitPython)<br>
|
|
43
43
|
[](https://pepy.tech/project/archunitpython) [](https://pepy.tech/project/archunitpython)
|
|
44
|
-
<!-- [](https://pypi.org/project/archunitpython/) -->
|
|
45
44
|
|
|
46
45
|
</div>
|
|
47
46
|
|
|
@@ -51,7 +50,7 @@ The #1 ArchUnit-style architecture testing library for Python, measured by GitHu
|
|
|
51
50
|
|
|
52
51
|
_Inspired by the amazing ArchUnit library but we are not affiliated with ArchUnit._
|
|
53
52
|
|
|
54
|
-
[Setup](#-setup) • [Use Cases](#-use-cases) • [Features](#-features) • [Why ArchUnitPython?](#-library-comparison) • [Sponsor](https://github.com/sponsors/LukasNiessen) • [Contributing](CONTRIBUTING.md)
|
|
53
|
+
[Setup](#-setup) • [Use Cases](#-use-cases) • [Features](#-features) • [Why ArchUnitPython?](#-library-comparison) • [Sponsor](https://github.com/sponsors/LukasNiessen) • [Contributing](CONTRIBUTING.md) • [Documentation](https://lukasniessen.github.io/ArchUnitPython/)
|
|
55
54
|
|
|
56
55
|
## ⚡ 5 min Quickstart
|
|
57
56
|
|
|
@@ -206,6 +205,42 @@ migrations/*.py
|
|
|
206
205
|
|
|
207
206
|
Patterns support comments, blank lines, glob syntax, root-relative paths, path
|
|
208
207
|
patterns, and directory patterns with a trailing `/`.
|
|
208
|
+
|
|
209
|
+
### Loading Common Rules From Config
|
|
210
|
+
|
|
211
|
+
For straightforward shared rules, you can load a JSON config file and still run
|
|
212
|
+
the resulting rules in your normal test suite:
|
|
213
|
+
|
|
214
|
+
```json
|
|
215
|
+
{
|
|
216
|
+
"project_path": "src",
|
|
217
|
+
"rules": [
|
|
218
|
+
{
|
|
219
|
+
"name": "controllers must not use services directly",
|
|
220
|
+
"type": "forbidden_dependency",
|
|
221
|
+
"source": "**/controllers/**",
|
|
222
|
+
"target": "**/services/**"
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
"name": "source files have no cycles",
|
|
226
|
+
"type": "no_cycles"
|
|
227
|
+
}
|
|
228
|
+
]
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
from archunitpython import assert_passes, rules_from_config
|
|
234
|
+
|
|
235
|
+
def test_configured_architecture_rules():
|
|
236
|
+
for rule in rules_from_config("archunitpython.json"):
|
|
237
|
+
assert_passes(rule)
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Supported rule types are `no_cycles`, `forbidden_dependency`, and
|
|
241
|
+
`forbidden_external_dependency`. The fluent Python API remains the primary and
|
|
242
|
+
most flexible interface.
|
|
243
|
+
|
|
209
244
|
### Explaining Rules With `.because(...)`
|
|
210
245
|
|
|
211
246
|
Attach a rationale to a rule so failing assertions explain why the rule exists:
|
|
@@ -342,6 +377,17 @@ ArchUnitPython detects string-based dynamic imports such as `importlib.import_mo
|
|
|
342
377
|
from my_app.adapters.sql import Repository # archunit: ignore
|
|
343
378
|
```
|
|
344
379
|
|
|
380
|
+
### Conditional Imports
|
|
381
|
+
|
|
382
|
+
Imports inside `try` blocks that handle `ImportError` or
|
|
383
|
+
`ModuleNotFoundError` are marked as conditional dependencies. This helps graph
|
|
384
|
+
reports distinguish optional imports and fallback implementations from regular
|
|
385
|
+
runtime imports. Conditional dependencies remain part of architecture checks;
|
|
386
|
+
relative and dynamic imports also retain their original import kind in graph
|
|
387
|
+
reports. An edge may therefore have multiple kinds: CSV reports use
|
|
388
|
+
pipe-delimited values such as `relative|conditional`, while HTML reports list
|
|
389
|
+
both values separately.
|
|
390
|
+
|
|
345
391
|
### Naming Conventions
|
|
346
392
|
|
|
347
393
|
```python
|
|
@@ -357,6 +403,10 @@ def test_naming_patterns():
|
|
|
357
403
|
|
|
358
404
|
### Code Metrics
|
|
359
405
|
|
|
406
|
+
Metric rules evaluate every matching file or class independently. Use filters such as
|
|
407
|
+
`in_folder()`, `with_name()`, and `for_classes_matching()` when different parts of the
|
|
408
|
+
project need different limits.
|
|
409
|
+
|
|
360
410
|
```python
|
|
361
411
|
def test_no_large_files():
|
|
362
412
|
rule = metrics("src/").count().lines_of_code().should_be_below(1000)
|
|
@@ -367,7 +417,7 @@ def test_high_class_cohesion():
|
|
|
367
417
|
assert_passes(rule)
|
|
368
418
|
|
|
369
419
|
def test_method_count():
|
|
370
|
-
rule = metrics("src/").count().method_count().
|
|
420
|
+
rule = metrics("src/").count().method_count().should_be_below_or_equal(20)
|
|
371
421
|
assert_passes(rule)
|
|
372
422
|
|
|
373
423
|
def test_field_count_for_data_classes():
|
|
@@ -381,6 +431,45 @@ def test_field_count_for_data_classes():
|
|
|
381
431
|
assert_passes(rule)
|
|
382
432
|
```
|
|
383
433
|
|
|
434
|
+
#### Comparison Semantics
|
|
435
|
+
|
|
436
|
+
Metric comparisons are exact. In particular, `should_be_below(20)` means `< 20`, so
|
|
437
|
+
a value of exactly `20` is a violation. Use the inclusive form when the limit itself
|
|
438
|
+
should be accepted.
|
|
439
|
+
|
|
440
|
+
| Method | Passing values |
|
|
441
|
+
| --- | --- |
|
|
442
|
+
| `should_be_below(n)` | `< n` |
|
|
443
|
+
| `should_be_below_or_equal(n)` | `<= n` |
|
|
444
|
+
| `should_be_above(n)` | `> n` |
|
|
445
|
+
| `should_be_above_or_equal(n)` | `>= n` |
|
|
446
|
+
| `should_be(n)` | exactly `n` |
|
|
447
|
+
|
|
448
|
+
Not every metric builder exposes every comparison. The available methods are shown
|
|
449
|
+
by the fluent API after selecting a metric.
|
|
450
|
+
|
|
451
|
+
#### Choosing Thresholds
|
|
452
|
+
|
|
453
|
+
There is no universal correct limit for every codebase. Treat thresholds as
|
|
454
|
+
architecture decisions that should reflect the role and maturity of the code:
|
|
455
|
+
|
|
456
|
+
1. Measure the current project before enabling a new rule.
|
|
457
|
+
2. Start at the current maximum, or slightly above it, to prevent further regression.
|
|
458
|
+
3. Use narrower filters when generated code, data classes, or adapters need different limits.
|
|
459
|
+
4. Lower the threshold gradually as existing violations are removed.
|
|
460
|
+
5. Record the reason with `.because(...)` so future maintainers understand the limit.
|
|
461
|
+
|
|
462
|
+
| Metric | Interpretation | Useful starting point |
|
|
463
|
+
| --- | --- | --- |
|
|
464
|
+
| Lines of code | File size and review burden | Current maximum for hand-written files |
|
|
465
|
+
| Method or field count | Class responsibility and size | Current maximum, split by class role |
|
|
466
|
+
| LCOM | Lack of class cohesion; lower is generally better | Baseline one LCOM variant and keep it consistent |
|
|
467
|
+
| Instability | Dependence on outgoing versus incoming dependencies | Compare files with similar architectural roles |
|
|
468
|
+
| Distance from main sequence | Balance between abstractness and instability; closer to zero is better | Observe the current distribution before tightening |
|
|
469
|
+
|
|
470
|
+
Thresholds are guardrails, not quality scores. A metric violation is a prompt to inspect
|
|
471
|
+
the design; it does not automatically mean the code is incorrect.
|
|
472
|
+
|
|
384
473
|
### Distance Metrics
|
|
385
474
|
|
|
386
475
|
```python
|
|
@@ -476,13 +565,13 @@ from archunitpython import project_graph
|
|
|
476
565
|
def test_export_dependency_graph_reports():
|
|
477
566
|
graph = project_graph("src/requests").titled("Application Architecture")
|
|
478
567
|
|
|
479
|
-
graph.collapse_to_folder_depth(2).export_as_mermaid("reports/dependencies.
|
|
568
|
+
graph.collapse_to_folder_depth(2).export_as_mermaid("reports/dependencies.mmd")
|
|
480
569
|
|
|
481
570
|
if __name__ == "__main__":
|
|
482
571
|
test_export_dependency_graph_reports()
|
|
483
572
|
```
|
|
484
|
-
**Exported
|
|
485
|
-
```
|
|
573
|
+
**Exported Mermaid diagram**
|
|
574
|
+
```mermaid
|
|
486
575
|
flowchart LR
|
|
487
576
|
n0["__init__.py"]
|
|
488
577
|
n1["__version__.py"]
|
|
@@ -600,20 +689,36 @@ When you create reports through `project_graph("src/")`, internal file paths are
|
|
|
600
689
|
|
|
601
690
|
### Reports
|
|
602
691
|
|
|
603
|
-
Generate HTML
|
|
692
|
+
Generate an HTML report from metric values collected by your tests or build tooling.
|
|
693
|
+
`MetricsExporter` formats the supplied dictionary; it does not execute metric rules or
|
|
694
|
+
calculate the values itself. _This feature is in beta._
|
|
604
695
|
|
|
605
696
|
```python
|
|
606
697
|
from archunitpython.metrics.fluentapi.export_utils import MetricsExporter, ExportOptions
|
|
607
698
|
|
|
608
|
-
|
|
609
|
-
|
|
699
|
+
metric_summary = {
|
|
700
|
+
"Maximum method count": "18 (limit: <= 20)",
|
|
701
|
+
"Maximum field count": "9 (limit: <= 10)",
|
|
702
|
+
"Maximum lines of code": "420 (limit: < 500)",
|
|
703
|
+
"Highest LCOM96b": "0.24 (limit: < 0.30)",
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
html = MetricsExporter.export_as_html(
|
|
707
|
+
metric_summary,
|
|
610
708
|
ExportOptions(
|
|
611
709
|
output_path="reports/metrics.html",
|
|
612
710
|
title="Architecture Metrics Dashboard",
|
|
711
|
+
include_timestamp=False,
|
|
613
712
|
),
|
|
614
713
|
)
|
|
714
|
+
|
|
715
|
+
assert "Maximum method count" in html
|
|
615
716
|
```
|
|
616
717
|
|
|
718
|
+
Keep labels and units stable if these reports are stored as CI artifacts and compared
|
|
719
|
+
between builds. Continue using executable metric rules with `assert_passes()` as the
|
|
720
|
+
enforcement mechanism.
|
|
721
|
+
|
|
617
722
|
## 🔎 Pattern Matching System
|
|
618
723
|
|
|
619
724
|
We offer three targeting options for pattern matching across all modules:
|
|
@@ -948,7 +1053,7 @@ If ArchUnitPython helps your project, please consider:
|
|
|
948
1053
|
|
|
949
1054
|
### Star History
|
|
950
1055
|
|
|
951
|
-
[](https://star-history.dera.page/#LukasNiessen/ArchUnitPython&Date)
|
|
952
1057
|
|
|
953
1058
|
## 📄 License
|
|
954
1059
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
archunitpython/__init__.py,sha256=
|
|
1
|
+
archunitpython/__init__.py,sha256=CU7R2ZvgfwPhbYLR99lt6Jy6NXLqXpYtf5M91Yo8i3Y,1282
|
|
2
2
|
archunitpython/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
3
|
archunitpython/common/__init__.py,sha256=TKL39Z0kBpWqMH99jU4LsDSUZ5lQ_rcAJfLnmUTDTOI,656
|
|
4
4
|
archunitpython/common/pattern_matching.py,sha256=HMAfo8GsooHvA4d2IxCi3btrYTdwfOQXw4bDNr117P0,2669
|
|
@@ -9,8 +9,8 @@ archunitpython/common/assertion/violation.py,sha256=TnMOykN3kPoGrSf1KcxBXYRfaxfX
|
|
|
9
9
|
archunitpython/common/error/__init__.py,sha256=UWcdIKpGAJvo4WEGOVSaUjQQnKoPi8R496uDGXBWKsE,116
|
|
10
10
|
archunitpython/common/error/errors.py,sha256=y7mcXoZPyK7uD2dMMO1qBt1y7KcIeGlRYiVeI6bjms4,249
|
|
11
11
|
archunitpython/common/extraction/__init__.py,sha256=RkJOcxJoLYQxEagh3uWhjsUCprK3Nr3Bn3o7nmzk4_Q,284
|
|
12
|
-
archunitpython/common/extraction/extract_graph.py,sha256=
|
|
13
|
-
archunitpython/common/extraction/graph.py,sha256=
|
|
12
|
+
archunitpython/common/extraction/extract_graph.py,sha256=SZ3-ZYI1RlIIF3saDoduXXzEHMwmflmhN60oWISeFOM,21608
|
|
13
|
+
archunitpython/common/extraction/graph.py,sha256=s4QW0WYfwjqXteBNygaNBdTgSC1XKil8cBFLtxl5rJE,1124
|
|
14
14
|
archunitpython/common/fluentapi/__init__.py,sha256=LeS7qS2p9-FqqBhDL8xRPex__W5Qk0UWfcpl7nVVLfI,178
|
|
15
15
|
archunitpython/common/fluentapi/checkable.py,sha256=BJFYibVhHLMFlnWdISPCIL3DYI1_3JN2-1jEQOaVaGE,1547
|
|
16
16
|
archunitpython/common/logging/__init__.py,sha256=u3-2_jYmiyoQ-C534SmQXvR1L9h3lbJqA9Yt1Nmv9HA,115
|
|
@@ -30,13 +30,15 @@ archunitpython/common/projection/cycles/tarjan_scc.py,sha256=pIj1ub8XgcqVLj8skdZ
|
|
|
30
30
|
archunitpython/common/util/__init__.py,sha256=g-W8kWiVqwdohxm74J3yUonPkb_yDUJrp73S5s4jpgI,117
|
|
31
31
|
archunitpython/common/util/declaration_detector.py,sha256=XgrpgaUIQa9MNFlnhVQz2X_uW7zCO9nrMbGa3tVFyPM,3407
|
|
32
32
|
archunitpython/common/util/logger.py,sha256=2did2mRAlkrElH9SYgv9xYiSzr4A-jXHg3cp6TodhoA,3326
|
|
33
|
+
archunitpython/config/__init__.py,sha256=jl8qQ040ej2h7KOxzNx4Xtct89YKCDZA2kcdJ-RiNK0,191
|
|
34
|
+
archunitpython/config/loader.py,sha256=sXsq8X3rijBT2C_t5EEn1GC-22ZVxxLYcFU4Fuinkcc,4301
|
|
33
35
|
archunitpython/files/__init__.py,sha256=wgl8IOqTeQGpNISu-Q8b_j6rTn8aEgoz6t1S5j8z13g,108
|
|
34
36
|
archunitpython/files/assertion/__init__.py,sha256=K2qiEFfmo6SCgxfOKNrPzYgT5ScKUaw2W2CrDa3jBTw,1068
|
|
35
|
-
archunitpython/files/assertion/custom_file_logic.py,sha256
|
|
37
|
+
archunitpython/files/assertion/custom_file_logic.py,sha256=-t9ziVc0NDXjjLnDB8kWDQ5en7JXo6NXzdcXsj_Ob-E,2970
|
|
36
38
|
archunitpython/files/assertion/cycle_free.py,sha256=Ib3tmVVErmTpORmRn_rZqOqY0gk54crN_d_IapeWqdI,738
|
|
37
39
|
archunitpython/files/assertion/depend_on_external_modules.py,sha256=BXGpN-301YOcB446e3U0i2rNNliQuaSJCfdZwKVzs0U,1845
|
|
38
|
-
archunitpython/files/assertion/depend_on_files.py,sha256=
|
|
39
|
-
archunitpython/files/assertion/matching_files.py,sha256=
|
|
40
|
+
archunitpython/files/assertion/depend_on_files.py,sha256=9lBj4YNe1jjKJV3r0ntsXfPHoKc_IbwgHhLgMYOs9Ok,2024
|
|
41
|
+
archunitpython/files/assertion/matching_files.py,sha256=1RyYqFA-IzzAXlgqRfiL18jk9xEJzEBY2fp_A3fxjdE,2015
|
|
40
42
|
archunitpython/files/fluentapi/__init__.py,sha256=wgl8IOqTeQGpNISu-Q8b_j6rTn8aEgoz6t1S5j8z13g,108
|
|
41
43
|
archunitpython/files/fluentapi/files.py,sha256=oZG0lWV6p0AiGANdoZfYd51H8zY-J4eTSPRTRPBYhs0,17152
|
|
42
44
|
archunitpython/graph/__init__.py,sha256=vm1jMOoN35IIU7YDs59qPtf_F82YV-QrEGfXhozAOek,756
|
|
@@ -63,7 +65,7 @@ archunitpython/metrics/fluentapi/metrics.py,sha256=8OBOL3jVKbM9v_CjNNM9FMfV1JBfl
|
|
|
63
65
|
archunitpython/metrics/projection/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
64
66
|
archunitpython/slices/__init__.py,sha256=tb8MoZiEqIdmWy7uyhmEtMNm5Fxjuw6atSvDsD131DE,96
|
|
65
67
|
archunitpython/slices/assertion/__init__.py,sha256=MRwV3d69ljAJ7hFYdxI6UDNC8U4Dm1ImeuLUVKEC9To,280
|
|
66
|
-
archunitpython/slices/assertion/admissible_edges.py,sha256
|
|
68
|
+
archunitpython/slices/assertion/admissible_edges.py,sha256=NNn1PGsK0LS_Msa3g1cAMQwLzxbot4rQNVRAHzW11pk,3124
|
|
67
69
|
archunitpython/slices/fluentapi/__init__.py,sha256=tb8MoZiEqIdmWy7uyhmEtMNm5Fxjuw6atSvDsD131DE,96
|
|
68
70
|
archunitpython/slices/fluentapi/slices.py,sha256=vNoyqmleTDTjnf2i35i2nte-Ycjm-oapfgaf3AhN0QI,7163
|
|
69
71
|
archunitpython/slices/projection/__init__.py,sha256=tVgcK4gw-bqhUq98vU7_iPE0NEhOPUr7vr1-p7JI1A4,237
|
|
@@ -77,7 +79,7 @@ archunitpython/testing/common/__init__.py,sha256=Wc4bC-N4t6giChKjj6wuTGKkb39thcY
|
|
|
77
79
|
archunitpython/testing/common/color_utils.py,sha256=2I8Z1SZfWhhgudMgmXY6PPydGxGl35cK_To-GXnSyJg,1226
|
|
78
80
|
archunitpython/testing/common/violation_factory.py,sha256=yWlvv2U7u-7KoTMfp1QCeJXxTLpPEagKqDHZmPSyrlk,4677
|
|
79
81
|
archunitpython/testing/pytest_plugin/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
80
|
-
archunitpython-1.
|
|
81
|
-
archunitpython-1.
|
|
82
|
-
archunitpython-1.
|
|
83
|
-
archunitpython-1.
|
|
82
|
+
archunitpython-1.6.0.dist-info/METADATA,sha256=Xi6XRsySw9v7D_T4pyNDQs2qGvZPenNKUMy8nqX4XW4,34913
|
|
83
|
+
archunitpython-1.6.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
84
|
+
archunitpython-1.6.0.dist-info/licenses/LICENSE,sha256=kaQWfzfHk45CNIx4sIW7Uf1sNW5rmo6BpZ-R8GruuK0,1102
|
|
85
|
+
archunitpython-1.6.0.dist-info/RECORD,,
|
|
File without changes
|