Skip to content
Merged
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
44 changes: 39 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,25 +102,30 @@ rules:

Isolate domain from infrastructure. Ports (interfaces) live in domain, adapters depend on domain but not vice versa.

Using named captures (`{context}`), you can enforce that each bounded context only depends on its own domain — not other contexts' domains:

```yaml
rules:
- name: domain-no-infra
modules: contexts.*.domain
modules: contexts.{context}.domain
allow:
standard_library: [dataclasses, typing, abc]
third_party: []
local: [contexts.*.domain]
local: [contexts.{context}.domain, shared.domain]

- name: adapters-depend-on-domain
modules: contexts.*.adapters
modules: contexts.{context}.adapters
allow:
standard_library: ["*"]
third_party: ["*"]
local:
- contexts.*.adapters
- contexts.*.domain
- contexts.{context}.adapters
- contexts.{context}.domain
- shared
```

With `{context}`, `contexts.boards.domain` can only import from `contexts.boards.domain` and `shared.domain` — not from `contexts.auth.domain`. See [Named Capture](#named-capture) for details.

## Configuration

### Include / Exclude
Expand Down Expand Up @@ -222,6 +227,35 @@ modules: contexts.*.domain # matches contexts.boards.domain, contexts.auth.doma
modules: contexts.**.domain # matches contexts.boards.domain, contexts.boards.sub.domain, ...
```

### Named Capture

`{name}` captures a single level (like `*`) and allows back-referencing the captured value in `allow` and `deny`:

```yaml
rules:
- name: domain-isolation
modules: contexts.{context}.domain
allow:
local: [contexts.{context}.domain, shared.domain]
```

When this rule matches `contexts.boards.domain`, `{context}` captures `"boards"`. The `allow` pattern `contexts.{context}.domain` resolves to `contexts.boards.domain`, so only the same context's domain is allowed.

You can use multiple captures in a single rule:

```yaml
rules:
- name: bounded-context-layers
modules: contexts.{context}.{layer}
allow:
local:
- contexts.{context}.{layer}
- contexts.{context}.domain
- shared
```

Named captures coexist with `*` and `**` wildcards. `{name}` always matches exactly one level.

### Submodule Matching

When a pattern is used in `allow` or `deny`, it also matches submodules of the matched module. For example:
Expand Down
40 changes: 40 additions & 0 deletions python_dependency_linter/checker.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,43 @@
from __future__ import annotations

import re
from dataclasses import dataclass

from python_dependency_linter.config import AllowDeny, Rule
from python_dependency_linter.matcher import matches_pattern
from python_dependency_linter.parser import ImportInfo
from python_dependency_linter.resolver import ImportCategory

_CAPTURE_RE = re.compile(r"\{(\w+)\}")


def resolve_captures(pattern: str, captures: dict[str, str]) -> str:
def _replace(m: re.Match) -> str:
name = m.group(1)
return captures.get(name, m.group(0))

return _CAPTURE_RE.sub(_replace, pattern)


def _resolve_list(
patterns: list[str] | None, captures: dict[str, str]
) -> list[str] | None:
if patterns is None:
return None
return [resolve_captures(p, captures) for p in patterns]


def _resolve_allow_deny(
allow_deny: AllowDeny | None, captures: dict[str, str]
) -> AllowDeny | None:
if allow_deny is None:
return None
return AllowDeny(
standard_library=_resolve_list(allow_deny.standard_library, captures),
third_party=_resolve_list(allow_deny.third_party, captures),
local=_resolve_list(allow_deny.local, captures),
)


@dataclass
class Violation:
Expand Down Expand Up @@ -60,10 +91,19 @@ def check_import(
category: ImportCategory,
merged_rule: Rule | None,
source_module: str,
captures: dict[str, str] | None = None,
) -> Violation | None:
if merged_rule is None:
return None

if captures:
merged_rule = Rule(
name=merged_rule.name,
modules=merged_rule.modules,
allow=_resolve_allow_deny(merged_rule.allow, captures),
deny=_resolve_allow_deny(merged_rule.deny, captures),
)

module = import_info.module

# Check deny first (deny takes priority over allow)
Expand Down
10 changes: 7 additions & 3 deletions python_dependency_linter/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,17 +115,21 @@ def check(config_path: str | None):
for file_path in python_files:
module = _file_to_module(file_path, root)
package = _package_module(file_path, root)
matching_rules = find_matching_rules(package, config.rules)
if not matching_rules:
matching = find_matching_rules(package, config.rules)
if not matching:
continue

matching_rules = [r for r, _ in matching]
captures: dict[str, str] = {}
for _, c in matching:
captures.update(c)
merged_rule = merge_rules(matching_rules)
imports = parse_imports(file_path, root)

file_violations = []
for imp in imports:
category = resolve_import(imp.module, root)
violation = check_import(imp, category, merged_rule, module)
violation = check_import(imp, category, merged_rule, module, captures)
if violation is not None:
file_violations.append(violation)

Expand Down
49 changes: 42 additions & 7 deletions python_dependency_linter/matcher.py
Original file line number Diff line number Diff line change
@@ -1,38 +1,73 @@
from __future__ import annotations

import re

from python_dependency_linter.config import AllowDeny, Rule

_CAPTURE_RE = re.compile(r"^\{(\w+)\}$")


def matches_pattern(pattern: str, module: str) -> bool:
return match_pattern_with_captures(pattern, module) is not None


def match_pattern_with_captures(pattern: str, module: str) -> dict[str, str] | None:
pattern_parts = pattern.split(".")
module_parts = module.split(".")
return _match(pattern_parts, module_parts)
captures: dict[str, str] = {}
if _match_with_captures(pattern_parts, module_parts, captures):
return captures
return None


def _match(pattern_parts: list[str], module_parts: list[str]) -> bool:
def _match_with_captures(
pattern_parts: list[str],
module_parts: list[str],
captures: dict[str, str],
) -> bool:
if not pattern_parts and not module_parts:
return True
if not pattern_parts:
return False

if pattern_parts[0] == "**":
# "**" matches one or more parts
for i in range(1, len(module_parts) + 1):
if _match(pattern_parts[1:], module_parts[i:]):
snapshot = dict(captures)
if _match_with_captures(pattern_parts[1:], module_parts[i:], captures):
return True
captures.clear()
captures.update(snapshot)
return False

if not module_parts:
return False

m = _CAPTURE_RE.match(pattern_parts[0])
if m:
name = m.group(1)
value = module_parts[0]
if name in captures:
if captures[name] != value:
return False
else:
captures[name] = value
return _match_with_captures(pattern_parts[1:], module_parts[1:], captures)

if pattern_parts[0] == "*" or pattern_parts[0] == module_parts[0]:
return _match(pattern_parts[1:], module_parts[1:])
return _match_with_captures(pattern_parts[1:], module_parts[1:], captures)

return False


def find_matching_rules(module: str, rules: list[Rule]) -> list[Rule]:
return [r for r in rules if matches_pattern(r.modules, module)]
def find_matching_rules(
module: str, rules: list[Rule]
) -> list[tuple[Rule, dict[str, str]]]:
result = []
for r in rules:
captures = match_pattern_with_captures(r.modules, module)
if captures is not None:
result.append((r, captures))
return result


def _merge_allow_deny(
Expand Down
75 changes: 74 additions & 1 deletion tests/test_checker.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from python_dependency_linter.checker import Violation, check_import
from python_dependency_linter.checker import Violation, check_import, resolve_captures
from python_dependency_linter.config import AllowDeny, Rule
from python_dependency_linter.parser import ImportInfo
from python_dependency_linter.resolver import ImportCategory
Expand Down Expand Up @@ -150,3 +150,76 @@ def test_no_allow_for_category_means_allow_all():
source_module="contexts.boards.domain",
)
assert result is None


def test_resolve_captures_single():
result = resolve_captures("src.contexts.{context}.domain", {"context": "analytics"})
assert result == "src.contexts.analytics.domain"


def test_resolve_captures_multiple():
result = resolve_captures(
"src.{ctx}.adapters.{dir}", {"ctx": "auth", "dir": "inbound"}
)
assert result == "src.auth.adapters.inbound"


def test_resolve_captures_no_placeholders():
result = resolve_captures("src.shared.domain", {"context": "analytics"})
assert result == "src.shared.domain"


def test_resolve_captures_unresolved_placeholder():
result = resolve_captures("src.{unknown}.domain", {"context": "analytics"})
assert result == "src.{unknown}.domain"


def test_cross_context_isolation_allowed():
"""Same context's domain import should be allowed."""
rule = Rule(
name="domain-layer",
modules="contexts.{context}.domain",
allow=AllowDeny(local=["contexts.{context}.domain", "shared.domain"]),
)
result = check_import(
import_info=ImportInfo(module="contexts.boards.domain.models", lineno=1),
category=ImportCategory.LOCAL,
merged_rule=rule,
source_module="contexts.boards.domain",
captures={"context": "boards"},
)
assert result is None


def test_cross_context_isolation_violation():
"""Different context's domain import should be denied."""
rule = Rule(
name="domain-layer",
modules="contexts.{context}.domain",
allow=AllowDeny(local=["contexts.{context}.domain", "shared.domain"]),
)
result = check_import(
import_info=ImportInfo(module="contexts.auth.domain.models", lineno=5),
category=ImportCategory.LOCAL,
merged_rule=rule,
source_module="contexts.boards.domain",
captures={"context": "boards"},
)
assert isinstance(result, Violation)
assert result.imported_module == "contexts.auth.domain.models"


def test_check_import_no_captures_backward_compat():
"""Existing behavior works when no captures provided."""
rule = Rule(
name="domain-isolation",
modules="contexts.*.domain",
allow=AllowDeny(third_party=["pydantic"]),
)
result = check_import(
import_info=ImportInfo(module="pydantic", lineno=1),
category=ImportCategory.THIRD_PARTY,
merged_rule=rule,
source_module="contexts.boards.domain",
)
assert result is None
Loading
Loading