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
88 changes: 60 additions & 28 deletions dep_check/checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,12 @@
"""

import re
from typing import List, Optional
from typing import List

from ordered_set import OrderedSet

from dep_check.dependency_finder import IParser
from dep_check.models import (
Dependency,
MatchingRule,
MatchingRules,
Module,
ModuleWildcard,
)
from dep_check.models import Dependency, MatchingRules, Module, ModuleWildcard


class NotAllowedDependencyException(Exception):
Expand All @@ -32,44 +26,82 @@ def __init__(
self.authorized_modules = authorized_modules


def _raise_on_forbidden_rules(
parser: IParser, dependency: Dependency, matching_rules: MatchingRules
) -> MatchingRules:
# pylint: disable=too-many-nested-blocks
used_rules: MatchingRules = OrderedSet()
imports = [
dependency.main_import,
*[
Module(f"{dependency.main_import}.{sub_import}")
for sub_import in dependency.sub_imports
],
]
for module in imports:
for matching_rule in matching_rules:
regex_rule = parser.wildcard_to_regex(matching_rule.specific_rule_wildcard)
if regex_rule.raise_if_found:
if re.match(f"{regex_rule.regex}$", module):
raise NotAllowedDependencyException(
module, [r.specific_rule_wildcard for r in matching_rules]
)
used_rules.add(matching_rule)

return used_rules


def _find_matching_rules(
parser: IParser, matching_rules: MatchingRules, dotted_import: Module
) -> MatchingRules:
used_rules: MatchingRules = OrderedSet()

for matching_rule in matching_rules:
regex_rule = parser.wildcard_to_regex(matching_rule.specific_rule_wildcard)
if regex_rule.raise_if_found:
# Don't want to handle if here, it should have been done earlier in the flow
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unclear what you're trying to mean here, do you imply that raise_if_found should have already been handled at this point ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, exactly

continue
if re.match(f"{regex_rule.regex}$", dotted_import):
used_rules.add(matching_rule)

return used_rules


def check_dependency(
parser: IParser, dependency: Dependency, matching_rules: MatchingRules
) -> MatchingRules:
"""
Check that dependencies match a given set of rules.
"""
used_rule: Optional[MatchingRule] = None
for matching_rule in matching_rules:
if re.match(
f"{parser.wildcard_to_regex(matching_rule.specific_rule_wildcard)}$",
dependency.main_import,
):
used_rule = matching_rule
return OrderedSet((used_rule,))

forbidden_rules = _raise_on_forbidden_rules(parser, dependency, matching_rules)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this imply that forbidden rules will suppress the output of unused rules ?

Copy link
Contributor Author

@theo-ardouin theo-ardouin May 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it means that the forbidden rules cannot be handled as the "normal" rules as they need to NOT raise "UnusedRule" (as we expect them to be unused, technically) and they need their own handling as we might miss imports per modules otherwise.

used_rules = _find_matching_rules(parser, matching_rules, dependency.main_import)
if used_rules:
return OrderedSet([*used_rules, *forbidden_rules])

if not dependency.sub_imports:
raise NotAllowedDependencyException(
dependency.main_import, [r.specific_rule_wildcard for r in matching_rules]
)

return check_import_from_dependency(parser, dependency, matching_rules)
return OrderedSet(
[
*check_import_from_dependency(parser, dependency, matching_rules),
*forbidden_rules,
]
)


def check_import_from_dependency(
parser: IParser, dependency: Dependency, matching_rules: MatchingRules
) -> MatchingRules:
used_rules: MatchingRules = OrderedSet()
for import_module in dependency.sub_imports:
used_rule = None
for matching_rule in matching_rules:
if re.match(
f"{parser.wildcard_to_regex(matching_rule.specific_rule_wildcard)}$",
f"{dependency.main_import}.{import_module}",
):
used_rule = matching_rule
used_rules.add(used_rule)
if not used_rule:
module = Module(f"{dependency.main_import}.{import_module}")
matched_rules = _find_matching_rules(parser, matching_rules, module)
used_rules.update(matched_rules)
if not matched_rules:
raise NotAllowedDependencyException(
Module(f"{dependency.main_import}.{import_module}"),
[r.specific_rule_wildcard for r in matching_rules],
module, [r.specific_rule_wildcard for r in matching_rules]
)
return used_rules
4 changes: 2 additions & 2 deletions dep_check/dependency_finder.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from abc import ABC, abstractmethod

from dep_check.models import Dependencies, ModuleWildcard, SourceFile
from dep_check.models import Dependencies, ModuleWildcard, RegexRule, SourceFile


class IParser(ABC):
Expand All @@ -9,7 +9,7 @@ class IParser(ABC):
"""

@abstractmethod
def wildcard_to_regex(self, module: ModuleWildcard) -> str:
def wildcard_to_regex(self, module: ModuleWildcard) -> RegexRule:
"""
Return a regex expression for the Module from wildcard
"""
Expand Down
12 changes: 10 additions & 2 deletions dep_check/infra/python_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
Dependency,
Module,
ModuleWildcard,
RegexRule,
SourceFile,
)

Expand Down Expand Up @@ -85,17 +86,24 @@ class PythonParser(IParser):
Implementation of the interface, to parse python
"""

def wildcard_to_regex(self, module: ModuleWildcard) -> str:
def wildcard_to_regex(self, module: ModuleWildcard) -> RegexRule:
"""
Return a regex expression for the Module from wildcard
"""
raise_if_found = False
module_regex = module.replace(".", "\\.").replace("*", ".*")
module_regex = module_regex.replace("[!", "[^").replace("?", ".?")
module_regex = module_regex.replace("(<", "(?P<")

# Special char including a module along with all its sub-modules:
module_regex = module_regex.replace("%", r"(\..*)?$")
return module_regex

# Special char to forbid a module
if module_regex.startswith("~"):
module_regex = module_regex[1:]
raise_if_found = True

return RegexRule(regex=module_regex, raise_if_found=raise_if_found)

def find_dependencies(self, source_file: SourceFile) -> Dependencies:
"""
Expand Down
6 changes: 6 additions & 0 deletions dep_check/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,9 @@ class SourceFile:

module: Module
code: SourceCode


@dataclass(frozen=True)
class RegexRule:
regex: str
raise_if_found: bool = False
2 changes: 1 addition & 1 deletion dep_check/use_cases/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ def _get_rules(self, module: Module) -> MatchingRules:
matching_rules: MatchingRules = OrderedSet()
for module_wildcard, rules in self.configuration.dependency_rules.items():
match = re.match(
f"{self.parser.wildcard_to_regex(ModuleWildcard(module_wildcard))}$",
f"{self.parser.wildcard_to_regex(ModuleWildcard(module_wildcard)).regex}$",
module,
)
if match:
Expand Down
4 changes: 2 additions & 2 deletions doc/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ You can build your own configuration file, using wildcards. Here are those suppo
* `[!d-y]` corresponds to any character which is **not** between 'd' and 'y'
* `[!abc]` corresponds to any character except 'a', 'b' or 'c'
* Use `%` after a module name (e.g. `my_module%`) to include this module along with its sub-modules.
* Use `~` before a module name (e.g. `~my_module`) to forbid this module.

### Examples

Expand Down Expand Up @@ -96,7 +97,7 @@ mymodule:
- '*'
```

### Advance usage
### Advanced usage

You can make dynamic rules using following syntax:

Expand All @@ -110,7 +111,6 @@ mymodule.(<submodule>%).module:
- othermodul?_[0-9]
```


## Check your configuration

Once your config file is ready, run
Expand Down
90 changes: 90 additions & 0 deletions tests/test_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,3 +345,93 @@ def test_not_used_dynamic_rule() -> None:

# Then
assert report_printer.print_report.call_args[0][0] == []


def test_passing_inverted_rule() -> None:
# Given
dep_rules = {
"foo": [
ModuleWildcard("bar%"),
ModuleWildcard("~bar.baz"),
]
}
configuration = Configuration(dep_rules)
source_file = SourceFile(
Module("foo"),
SourceCode("import bar\nfrom bar import submodule\n"),
)
report_printer = Mock()
use_case = CheckDependenciesUC(
configuration,
report_printer,
PARSER,
iter([source_file]),
)

# When
use_case.run()

# Then
assert not report_printer.print_report.call_args[0][0]


def test_not_passing_inverted_rule() -> None:
# Given
dep_rules = {
"foo": [
ModuleWildcard("bar%"),
ModuleWildcard("~bar.baz%"),
]
}
configuration = Configuration(dep_rules)
source_file = SourceFile(
Module("foo"),
SourceCode("import bar\nfrom bar import baz\n"),
)
report_printer = Mock()
use_case = CheckDependenciesUC(
configuration,
report_printer,
PARSER,
iter([source_file]),
)

# When
with pytest.raises(ForbiddenDepencyError):
use_case.run()

# Then
assert report_printer.print_report.call_args[0][0] == OrderedSet(
(
DependencyError(
module=Module("foo"),
dependency=Module("bar.baz"),
rules=(ModuleWildcard("bar%"), ModuleWildcard("~bar.baz%")),
),
)
)


def test_passing_inverted_no_unused_rule() -> None:
# Given
dep_rules = {
"foo": [
ModuleWildcard("bar%"),
ModuleWildcard("~baz%"),
]
}
configuration = Configuration(dep_rules, unused_level=UnusedLevel.ERROR.value)
source_file = SourceFile(Module("foo"), SourceCode("import bar"))
report_printer = Mock()
use_case = CheckDependenciesUC(
configuration,
report_printer,
PARSER,
iter([source_file]),
)

# When
use_case.run()

# Then
assert not report_printer.print_report.call_args[0][0]
Loading