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.
@@ -1,6 +1,6 @@
1
1
  """ArchUnitPython - Architecture testing library for Python projects."""
2
2
 
3
- __version__ = "1.4.0"
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, import_kind
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=(import_kind,),
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
- is_type = _in_type_checking(node, type_checking_ranges)
306
- kind = ImportKind.TYPE_IMPORT if is_type else ImportKind.IMPORT
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(_LocatedImport(alias.name, kind, node.lineno))
326
+ imports.append(
327
+ _LocatedImport(alias.name, kind, node.lineno, syntax_kind)
328
+ )
309
329
 
310
330
  elif isinstance(node, ast.ImportFrom):
311
- is_type = _in_type_checking(node, type_checking_ranges)
312
- if node.level and node.level > 0:
313
- # Relative import
314
- kind = ImportKind.TYPE_IMPORT if is_type else ImportKind.RELATIVE_IMPORT
315
- module = node.module or ""
316
- dots = "." * node.level
317
- imports.append(_LocatedImport(f"{dots}{module}", kind, node.lineno))
318
- else:
319
- kind = ImportKind.TYPE_IMPORT if is_type else ImportKind.FROM_IMPORT
320
- if node.module:
321
- imports.append(_LocatedImport(node.module, kind, node.lineno))
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
- is_type = _in_type_checking(node, type_checking_ranges)
325
- kind = ImportKind.TYPE_IMPORT if is_type else ImportKind.DYNAMIC_IMPORT
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(_LocatedImport(module_name, kind, node.lineno))
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 kind in (ImportKind.RELATIVE_IMPORT, ImportKind.TYPE_IMPORT) and import_name.startswith("."):
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,5 @@
1
+ """Configuration-file support for common architecture rules."""
2
+
3
+ from archunitpython.config.loader import ConfiguredRule, rules_from_config
4
+
5
+ __all__ = ["ConfiguredRule", "rules_from_config"]
@@ -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 (should adhere), violation when condition returns False.
76
- If True (shouldNot adhere), violation when condition returns 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
- # shouldNot: violation if condition IS True
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 (shouldNot), files matching subject must NOT
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
- # shouldNot: violation if dependency EXISTS
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 (shouldNot), files must NOT match any pattern.
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
- # shouldNot: violation if file DOES match
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 shouldNot).
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.4
1
+ Metadata-Version: 2.5
2
2
  Name: archunitpython
3
- Version: 1.4.0
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
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Build & tests](https://img.shields.io/github/actions/workflow/status/LukasNiessen/ArchUnitPython/integrate.yaml?branch=main&label=build%20%26%20tests)](https://github.com/LukasNiessen/ArchUnitPython/actions/workflows/integrate.yaml) [![GitHub stars](https://img.shields.io/github/stars/LukasNiessen/ArchUnitPython.svg)](https://github.com/LukasNiessen/ArchUnitPython)<br>
43
43
  [![PyPI downloads](https://static.pepy.tech/badge/archunitpython/month)](https://pepy.tech/project/archunitpython) [![PyPI total downloads](https://img.shields.io/pepy/dt/archunitpython?label=total%20downloads&color=007ec6)](https://pepy.tech/project/archunitpython)
44
- <!-- [![PyPI version](https://img.shields.io/pypi/v/archunitpython.svg)](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().should_be_below(20)
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.md")
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 mermaid diagram**
485
- ``` mermaid
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 reports for your metrics. _Note that this feature is in beta._
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
- MetricsExporter.export_as_html(
609
- {"MethodCount": 5, "FieldCount": 3, "LinesOfCode": 150},
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
- [![Star History Chart](https://api.star-history.com/svg?repos=LukasNiessen/ArchUnitPython&type=Date)](https://www.star-history.com/#LukasNiessen/ArchUnitPython&Date)
1056
+ [![Star History Chart](https://star-history.dera.page/svg?repos=LukasNiessen/ArchUnitPython&type=Date)](https://star-history.dera.page/#LukasNiessen/ArchUnitPython&Date)
952
1057
 
953
1058
  ## 📄 License
954
1059
 
@@ -1,4 +1,4 @@
1
- archunitpython/__init__.py,sha256=8MQMKFXjGNHJdCtifcWJcAgXLkjqJhDiXYxqp2fmxlQ,1154
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=PcKInkTFLBxWO5RqppcGJKDii2LrTXgkxm7a0G16ZAg,17186
13
- archunitpython/common/extraction/graph.py,sha256=Rk-0eDDOLoHvScO27J2JzXjyMq3e5eKtrHnFBh5B6SU,1052
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=1v5D80QX_NnAKBwKwyhetY4rA8rrXyxN2BsHlvnLgq8,2956
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=EGMtDD8KT09BJQwOEMoKj-yTQfY3KLLYWN5SM1BCmpY,2012
39
- archunitpython/files/assertion/matching_files.py,sha256=qNbvU2o_MM6rzM-YUdJZzkltVbenWGC0u5kLH7su6CA,2003
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=-3o5mGtl81i5I_572x3Z4ZDfqPCyFqjGd7Dg8GRSKhY,3119
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.4.0.dist-info/METADATA,sha256=z6zirY281brtJcOrmADYb_Mrll-MNzEC6GKuTPQRkA4,30762
81
- archunitpython-1.4.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
82
- archunitpython-1.4.0.dist-info/licenses/LICENSE,sha256=kaQWfzfHk45CNIx4sIW7Uf1sNW5rmo6BpZ-R8GruuK0,1102
83
- archunitpython-1.4.0.dist-info/RECORD,,
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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.31.0
2
+ Generator: hatchling 1.32.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any