Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions src/archunitpython/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,6 +36,9 @@
# Layers
"project_layers",
"layers",
# Config
"rules_from_config",
"ConfiguredRule",
# Slices
"project_slices",
# Metrics
Expand Down
5 changes: 5 additions & 0 deletions src/archunitpython/config/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
119 changes: 119 additions & 0 deletions src/archunitpython/config/loader.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions tests/config/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests for configuration-file rule loading."""
115 changes: 115 additions & 0 deletions tests/config/test_config_loader.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 5 additions & 0 deletions tests/integration/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down