diff --git a/README.md b/README.md index e196038..c96f033 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: diff --git a/python_dependency_linter/checker.py b/python_dependency_linter/checker.py index 9859f2a..1bb4294 100644 --- a/python_dependency_linter/checker.py +++ b/python_dependency_linter/checker.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from dataclasses import dataclass from python_dependency_linter.config import AllowDeny, Rule @@ -7,6 +8,36 @@ 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: @@ -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) diff --git a/python_dependency_linter/cli.py b/python_dependency_linter/cli.py index 2c510b9..f0ea728 100644 --- a/python_dependency_linter/cli.py +++ b/python_dependency_linter/cli.py @@ -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) diff --git a/python_dependency_linter/matcher.py b/python_dependency_linter/matcher.py index 913a9ae..52a06e3 100644 --- a/python_dependency_linter/matcher.py +++ b/python_dependency_linter/matcher.py @@ -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( diff --git a/tests/test_checker.py b/tests/test_checker.py index fea0baf..dc288d8 100644 --- a/tests/test_checker.py +++ b/tests/test_checker.py @@ -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 @@ -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 diff --git a/tests/test_matcher.py b/tests/test_matcher.py index d5f71d9..3469c1f 100644 --- a/tests/test_matcher.py +++ b/tests/test_matcher.py @@ -1,6 +1,7 @@ from python_dependency_linter.config import AllowDeny, Rule from python_dependency_linter.matcher import ( find_matching_rules, + match_pattern_with_captures, matches_pattern, merge_rules, ) @@ -73,8 +74,8 @@ def test_find_matching_rules(): ] matched = find_matching_rules("contexts.boards.domain", rules) assert len(matched) == 2 - assert matched[0].name == "r1" - assert matched[1].name == "r2" + assert matched[0][0].name == "r1" + assert matched[1][0].name == "r2" def test_merge_rules_merges_allow(): @@ -117,3 +118,99 @@ def test_merge_rules_merges_deny(): merged = merge_rules([rule1, rule2]) assert sorted(merged.deny.third_party) == ["boto3", "requests"] + + +def test_capture_single(): + result = match_pattern_with_captures( + "src.contexts.{context}.domain", "src.contexts.analytics.domain" + ) + assert result == {"context": "analytics"} + + +def test_capture_multiple(): + result = match_pattern_with_captures( + "src.contexts.{ctx}.adapters.{dir}", "src.contexts.auth.adapters.inbound" + ) + assert result == {"ctx": "auth", "dir": "inbound"} + + +def test_capture_duplicate_name_consistent(): + result = match_pattern_with_captures("src.{a}.middle.{a}", "src.foo.middle.foo") + assert result == {"a": "foo"} + + +def test_capture_duplicate_name_inconsistent(): + result = match_pattern_with_captures("src.{a}.middle.{a}", "src.foo.middle.bar") + assert result is None + + +def test_capture_no_match(): + result = match_pattern_with_captures( + "src.contexts.{context}.domain", "src.utils.helpers" + ) + assert result is None + + +def test_capture_no_captures_with_star(): + result = match_pattern_with_captures("src.*.domain", "src.analytics.domain") + assert result == {} + + +def test_capture_coexist_with_star(): + result = match_pattern_with_captures( + "src.{ctx}.*.domain", "src.auth.adapters.domain" + ) + assert result == {"ctx": "auth"} + + +def test_capture_coexist_with_double_star(): + result = match_pattern_with_captures( + "src.{ctx}.**.domain", "src.auth.deep.nested.domain" + ) + assert result == {"ctx": "auth"} + + +def test_capture_exact_no_wildcards(): + result = match_pattern_with_captures( + "src.contexts.analytics.domain", "src.contexts.analytics.domain" + ) + assert result == {} + + +def test_capture_exact_no_wildcards_no_match(): + result = match_pattern_with_captures( + "src.contexts.analytics.domain", "src.contexts.auth.domain" + ) + assert result is None + + +def test_find_matching_rules_with_captures(): + rules = [ + Rule( + name="domain-layer", + modules="contexts.{context}.domain", + allow=AllowDeny(local=["contexts.{context}.domain"]), + ), + Rule( + name="adapters", + modules="contexts.*.adapters", + deny=AllowDeny(third_party=["boto3"]), + ), + ] + matched = find_matching_rules("contexts.boards.domain", rules) + assert len(matched) == 1 + rule, captures = matched[0] + assert rule.name == "domain-layer" + assert captures == {"context": "boards"} + + +def test_capture_after_double_star(): + result = match_pattern_with_captures( + "src.**.{layer}.models", "src.deep.nested.domain.models" + ) + assert result == {"layer": "domain"} + + +def test_capture_after_double_star_backtrack(): + result = match_pattern_with_captures("**.{x}.end", "a.b.c.end") + assert result == {"x": "c"}