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
21 changes: 20 additions & 1 deletion python_dependency_linter/matcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,31 @@ def _match_with_captures(
return False


def match_pattern_with_captures_or_submodule(
pattern: str, module: str
) -> dict[str, str] | None:
"""Match pattern exactly or treat module as a submodule of the pattern."""
captures = match_pattern_with_captures(pattern, module)
if captures is not None:
return captures
# Check if a prefix of the module matches the pattern.
# e.g. "contexts.*.domain" should match "contexts.boards.domain.models"
module_parts = module.split(".")
pattern_parts = pattern.split(".")
if len(module_parts) > len(pattern_parts):
prefix = ".".join(module_parts[: len(pattern_parts)])
captures = match_pattern_with_captures(pattern, prefix)
if captures is not None:
return captures
return None


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)
captures = match_pattern_with_captures_or_submodule(r.modules, module)
if captures is not None:
result.append((r, captures))
return result
Expand Down
59 changes: 59 additions & 0 deletions tests/test_matcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,62 @@ def test_capture_after_double_star():
def test_capture_after_double_star_backtrack():
result = match_pattern_with_captures("**.{x}.end", "a.b.c.end")
assert result == {"x": "c"}


def test_find_matching_rules_submodule():
"""modules pattern should match submodules automatically."""
rules = [
Rule(
name="domain-layer",
modules="contexts.*.domain",
allow=AllowDeny(local=["contexts.*.domain"]),
),
]
# Exact match still works
matched = find_matching_rules("contexts.boards.domain", rules)
assert len(matched) == 1
assert matched[0][0].name == "domain-layer"

# Submodule should also match
matched = find_matching_rules("contexts.boards.domain.models", rules)
assert len(matched) == 1
assert matched[0][0].name == "domain-layer"

# Deeper submodule should also match
matched = find_matching_rules("contexts.boards.domain.entities.metric", rules)
assert len(matched) == 1
assert matched[0][0].name == "domain-layer"

# Non-matching module should not match
matched = find_matching_rules("contexts.boards.application.service", rules)
assert len(matched) == 0


def test_find_matching_rules_submodule_with_captures():
"""modules submodule matching should preserve captures."""
rules = [
Rule(
name="domain-layer",
modules="contexts.{context}.domain",
allow=AllowDeny(local=["contexts.{context}.domain"]),
),
]
matched = find_matching_rules("contexts.boards.domain.models", rules)
assert len(matched) == 1
rule, captures = matched[0]
assert rule.name == "domain-layer"
assert captures == {"context": "boards"}


def test_find_matching_rules_submodule_exact_pattern():
"""Exact (no wildcard) modules pattern should also match submodules."""
rules = [
Rule(
name="shared",
modules="src.shared.domain",
allow=AllowDeny(local=["src.shared.domain"]),
),
]
matched = find_matching_rules("src.shared.domain.entity.user", rules)
assert len(matched) == 1
assert matched[0][0].name == "shared"
Loading