diff --git a/BACKLOG.md b/BACKLOG.md index 6d4a342..7047ea3 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -13,7 +13,7 @@ This backlog collects product and maintenance ideas from project research. - Add an `.archignore` or similar file, modeled after `.gitignore`, for files that should never be analyzed. - Add a `.because(...)` API so rules can carry user-facing rationale into failure messages and generated architecture documentation. -- Add configuration-file support for common rules, while keeping the fluent Python API as the primary interface. +- [x] Add configuration-file support for common rules, while keeping the fluent Python API as the primary interface. - Add support for monorepo and multi-package Python projects. ## P1 - Python Import Semantics diff --git a/README.md b/README.md index 9cca0bc..1a17fa0 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,41 @@ options = CheckOptions( violations = rule.check(options) ``` +### Loading Common Rules From Config + +For straightforward shared rules, you can load a JSON config file and still run +the resulting rules in your normal test suite: + +```json +{ + "project_path": "src", + "rules": [ + { + "name": "controllers must not use services directly", + "type": "forbidden_dependency", + "source": "**/controllers/**", + "target": "**/services/**" + }, + { + "name": "source files have no cycles", + "type": "no_cycles" + } + ] +} +``` + +```python +from archunitpython import assert_passes, rules_from_config + +def test_configured_architecture_rules(): + for rule in rules_from_config("archunitpython.json"): + assert_passes(rule) +``` + +Supported rule types are `no_cycles`, `forbidden_dependency`, and +`forbidden_external_dependency`. The fluent Python API remains the primary and +most flexible interface. + ## 🐹 Use Cases Here is an overview of common use cases. diff --git a/src/archunitpython/__init__.py b/src/archunitpython/__init__.py index 65a28d7..ad11a5e 100644 --- a/src/archunitpython/__init__.py +++ b/src/archunitpython/__init__.py @@ -12,6 +12,7 @@ Violation, ) from archunitpython.common.extraction import clear_graph_cache, extract_graph +from archunitpython.config import ConfiguredRule, rules_from_config from archunitpython.files import files, project_files from archunitpython.graph import dependency_graph, project_graph from archunitpython.layers import layers, project_layers @@ -35,6 +36,9 @@ # Layers "project_layers", "layers", + # Config + "rules_from_config", + "ConfiguredRule", # Slices "project_slices", # Metrics diff --git a/src/archunitpython/config/__init__.py b/src/archunitpython/config/__init__.py new file mode 100644 index 0000000..53f3e3a --- /dev/null +++ b/src/archunitpython/config/__init__.py @@ -0,0 +1,5 @@ +"""Configuration-file support for common architecture rules.""" + +from archunitpython.config.loader import ConfiguredRule, rules_from_config + +__all__ = ["ConfiguredRule", "rules_from_config"] diff --git a/src/archunitpython/config/loader.py b/src/archunitpython/config/loader.py new file mode 100644 index 0000000..854545f --- /dev/null +++ b/src/archunitpython/config/loader.py @@ -0,0 +1,119 @@ +"""Load common architecture rules from a JSON configuration file.""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from archunitpython.common.assertion.violation import Violation +from archunitpython.common.error.errors import UserError +from archunitpython.common.fluentapi.checkable import Checkable, CheckOptions +from archunitpython.files.fluentapi.files import project_files + + +@dataclass(frozen=True) +class ConfiguredRule: + """A named rule loaded from a configuration file.""" + + name: str + rule: Checkable + + def check(self, options: CheckOptions | None = None) -> list[Violation]: + """Run the configured rule.""" + return self.rule.check(options) + + +def rules_from_config(config_path: str) -> list[ConfiguredRule]: + """Load common architecture rules from a JSON config file. + + The fluent Python API remains the primary interface. Config files provide a + lightweight way to share straightforward rules across projects or teams. + """ + path = Path(config_path) + try: + raw_config = json.loads(path.read_text(encoding="utf-8")) + except OSError as exc: + raise UserError(f"Could not read config file: {config_path}") from exc + except json.JSONDecodeError as exc: + raise UserError(f"Invalid JSON config file: {config_path}") from exc + + if not isinstance(raw_config, dict): + raise UserError("Architecture config must be a JSON object.") + + project_path = _optional_string(raw_config, "project_path") or os.getcwd() + rules = raw_config.get("rules") + if not isinstance(rules, list): + raise UserError("Architecture config must define a 'rules' list.") + + base_dir = str(path.parent if path.parent != Path("") else Path.cwd()) + resolved_project_path = _resolve_project_path(base_dir, project_path) + + return [_build_rule(resolved_project_path, item, index) for index, item in enumerate(rules, 1)] + + +def _build_rule(project_path: str, item: Any, index: int) -> ConfiguredRule: + if not isinstance(item, dict): + raise UserError(f"Rule #{index} must be a JSON object.") + + rule_type = _required_string(item, "type", index) + name = _optional_string(item, "name") or f"{rule_type} rule #{index}" + rule: Checkable + if rule_type == "no_cycles": + subject = _optional_string(item, "subject") + builder = project_files(project_path) + if subject is not None: + rule = builder.in_path(subject).should().have_no_cycles() + else: + rule = builder.should().have_no_cycles() + elif rule_type == "forbidden_dependency": + source = _required_string(item, "source", index) + target = _required_string(item, "target", index) + rule = ( + project_files(project_path) + .in_path(source) + .should_not() + .depend_on_files() + .in_path(target) + ) + elif rule_type == "forbidden_external_dependency": + source = _required_string(item, "source", index) + module = _required_string(item, "module", index) + rule = ( + project_files(project_path) + .in_path(source) + .should_not() + .depend_on_external_modules() + .matching(module) + ) + else: + raise UserError( + f"Unsupported rule type '{rule_type}'. Supported types: " + "no_cycles, forbidden_dependency, forbidden_external_dependency." + ) + + return ConfiguredRule(name=name, rule=rule) + + +def _resolve_project_path(base_dir: str, project_path: str) -> str: + if os.path.isabs(project_path): + return project_path + return os.path.abspath(os.path.join(base_dir, project_path)) + + +def _required_string(rule: dict[str, Any], key: str, index: int) -> str: + value = rule.get(key) + if not isinstance(value, str) or not value.strip(): + raise UserError(f"Rule #{index} must define a non-empty string '{key}'.") + return value + + +def _optional_string(rule: dict[str, Any], key: str) -> str | None: + value = rule.get(key) + if value is None: + return None + if not isinstance(value, str) or not value.strip(): + raise UserError(f"Config value '{key}' must be a non-empty string.") + return value diff --git a/tests/config/__init__.py b/tests/config/__init__.py new file mode 100644 index 0000000..332c38d --- /dev/null +++ b/tests/config/__init__.py @@ -0,0 +1 @@ +"""Tests for configuration-file rule loading.""" diff --git a/tests/config/test_config_loader.py b/tests/config/test_config_loader.py new file mode 100644 index 0000000..6f0d85b --- /dev/null +++ b/tests/config/test_config_loader.py @@ -0,0 +1,115 @@ +"""Tests for loading architecture rules from JSON config files.""" + +import json +import shutil +from pathlib import Path +from uuid import uuid4 + +import pytest + +from archunitpython.common.error.errors import UserError +from archunitpython.config import rules_from_config +from archunitpython.files.assertion.depend_on_external_modules import ( + ViolatingExternalModuleDependency, +) +from archunitpython.files.assertion.depend_on_files import ViolatingFileDependency + + +class TestRulesFromConfig: + def setup_method(self): + self._temp_dir = Path(__file__).resolve().parent / ".tmp" / f"project_{uuid4().hex}" + self._temp_dir.mkdir(parents=True) + + def teardown_method(self): + shutil.rmtree(self._temp_dir, ignore_errors=True) + + def _write(self, relative_path: str, content: str) -> None: + path = self._temp_dir / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + def _write_config(self, config: dict[str, object]) -> str: + path = self._temp_dir / "archunitpython.json" + path.write_text(json.dumps(config), encoding="utf-8") + return str(path) + + def test_loads_no_cycles_rule(self): + self._write("src/model.py", "class Model:\n pass\n") + self._write("src/service.py", "from model import Model\n") + config_path = self._write_config( + { + "project_path": "src", + "rules": [ + { + "name": "source files have no cycles", + "type": "no_cycles", + } + ], + } + ) + + rules = rules_from_config(config_path) + + assert [rule.name for rule in rules] == ["source files have no cycles"] + assert rules[0].check() == [] + + def test_loads_forbidden_dependency_rule(self): + self._write("src/controllers/controller.py", "from services.service import Service\n") + self._write("src/services/service.py", "class Service:\n pass\n") + config_path = self._write_config( + { + "project_path": "src", + "rules": [ + { + "type": "forbidden_dependency", + "source": "**/controllers/**", + "target": "**/services/**", + } + ], + } + ) + + rules = rules_from_config(config_path) + violations = rules[0].check() + + assert any(isinstance(v, ViolatingFileDependency) for v in violations) + + def test_loads_forbidden_external_dependency_rule(self): + self._write("src/utils/helpers.py", "import json\n") + config_path = self._write_config( + { + "project_path": "src", + "rules": [ + { + "type": "forbidden_external_dependency", + "source": "**/utils/**", + "module": "json", + } + ], + } + ) + + rules = rules_from_config(config_path) + violations = rules[0].check() + + assert any(isinstance(v, ViolatingExternalModuleDependency) for v in violations) + + def test_rejects_missing_rules_list(self): + config_path = self._write_config({"project_path": "src"}) + + with pytest.raises(UserError, match="rules"): + rules_from_config(config_path) + + def test_rejects_unknown_rule_type(self): + config_path = self._write_config( + { + "rules": [ + { + "type": "unknown", + } + ] + } + ) + + with pytest.raises(UserError, match="Unsupported rule type"): + rules_from_config(config_path) diff --git a/tests/integration/test_e2e.py b/tests/integration/test_e2e.py index 6a440ab..b980136 100644 --- a/tests/integration/test_e2e.py +++ b/tests/integration/test_e2e.py @@ -42,6 +42,11 @@ def test_import_project_graph(self): def test_import_project_layers(self): assert callable(project_layers) + def test_import_rules_from_config(self): + from archunitpython import rules_from_config + + assert callable(rules_from_config) + def test_import_metrics(self): from archunitpython import metrics