diff --git a/dep_check/checker.py b/dep_check/checker.py index 70fc0d3..3f4c6c5 100644 --- a/dep_check/checker.py +++ b/dep_check/checker.py @@ -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): @@ -32,26 +26,70 @@ 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 + 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) + 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( @@ -59,17 +97,11 @@ def check_import_from_dependency( ) -> 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 diff --git a/dep_check/dependency_finder.py b/dep_check/dependency_finder.py index 082ed11..3bf94d1 100644 --- a/dep_check/dependency_finder.py +++ b/dep_check/dependency_finder.py @@ -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): @@ -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 """ diff --git a/dep_check/infra/python_parser.py b/dep_check/infra/python_parser.py index 3325ae9..47e23fd 100644 --- a/dep_check/infra/python_parser.py +++ b/dep_check/infra/python_parser.py @@ -9,6 +9,7 @@ Dependency, Module, ModuleWildcard, + RegexRule, SourceFile, ) @@ -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: """ diff --git a/dep_check/models.py b/dep_check/models.py index 1bdfbb6..2efef1a 100644 --- a/dep_check/models.py +++ b/dep_check/models.py @@ -75,3 +75,9 @@ class SourceFile: module: Module code: SourceCode + + +@dataclass(frozen=True) +class RegexRule: + regex: str + raise_if_found: bool = False diff --git a/dep_check/use_cases/check.py b/dep_check/use_cases/check.py index 8af04a1..2d9760d 100644 --- a/dep_check/use_cases/check.py +++ b/dep_check/use_cases/check.py @@ -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: diff --git a/doc/usage.md b/doc/usage.md index a3daabe..c2cebb1 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -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 @@ -96,7 +97,7 @@ mymodule: - '*' ``` -### Advance usage +### Advanced usage You can make dynamic rules using following syntax: @@ -110,7 +111,6 @@ mymodule.(%).module: - othermodul?_[0-9] ``` - ## Check your configuration Once your config file is ready, run diff --git a/tests/test_check.py b/tests/test_check.py index 36b6465..fb6e407 100644 --- a/tests/test_check.py +++ b/tests/test_check.py @@ -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] diff --git a/tests/test_parser.py b/tests/test_parser.py index 693d819..27ddcf5 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -191,10 +191,11 @@ def test_empty() -> None: module = ModuleWildcard("") # When - regex = PARSER.wildcard_to_regex(module) + regex_rule = PARSER.wildcard_to_regex(module) # Then - assert regex == "" + assert regex_rule.regex == "" + assert not regex_rule.raise_if_found @staticmethod def test_simple_module() -> None: @@ -205,12 +206,13 @@ def test_simple_module() -> None: module = ModuleWildcard("toto") # When - regex = PARSER.wildcard_to_regex(module) + regex_rule = PARSER.wildcard_to_regex(module) # Then - assert re.match(regex, "toto") - assert not re.match(regex, "tata") - assert not re.match(regex, "titi.toto") + assert re.match(regex_rule.regex, "toto") + assert not re.match(regex_rule.regex, "tata") + assert not re.match(regex_rule.regex, "titi.toto") + assert not regex_rule.raise_if_found @staticmethod def test_nested_module() -> None: @@ -221,13 +223,14 @@ def test_nested_module() -> None: module = ModuleWildcard("toto.tata") # When - regex = PARSER.wildcard_to_regex(module) + regex_rule = PARSER.wildcard_to_regex(module) # Then - assert re.match(regex, "toto.tata") - assert not re.match(regex, "toto") - assert not re.match(regex, "tata") - assert not re.match(regex, "titi.toto") + assert re.match(regex_rule.regex, "toto.tata") + assert not re.match(regex_rule.regex, "toto") + assert not re.match(regex_rule.regex, "tata") + assert not re.match(regex_rule.regex, "titi.toto") + assert not regex_rule.raise_if_found @staticmethod def test_quesiton_mark() -> None: @@ -238,15 +241,16 @@ def test_quesiton_mark() -> None: module = ModuleWildcard("t?to.?at?") # When - regex = PARSER.wildcard_to_regex(module) + regex_rule = PARSER.wildcard_to_regex(module) # Then - assert re.match(regex, "toto.tata") - assert re.match(regex, "t2to.bato") - assert re.match(regex, "t#to.!at&") - assert not re.match(regex, "toto") - assert not re.match(regex, "tata") - assert not re.match(regex, "toti.toto") + assert re.match(regex_rule.regex, "toto.tata") + assert re.match(regex_rule.regex, "t2to.bato") + assert re.match(regex_rule.regex, "t#to.!at&") + assert not re.match(regex_rule.regex, "toto") + assert not re.match(regex_rule.regex, "tata") + assert not re.match(regex_rule.regex, "toti.toto") + assert not regex_rule.raise_if_found @staticmethod def test_asterisk() -> None: @@ -257,15 +261,16 @@ def test_asterisk() -> None: module = ModuleWildcard("toto*.*") # When - regex = PARSER.wildcard_to_regex(module) + regex_rule = PARSER.wildcard_to_regex(module) # Then - assert re.match(regex, "toto.tata") - assert re.match(regex, "toto_2351.titi") - assert re.match(regex, "toto_azerty.titi.toto.tata") - assert not re.match(regex, "toto") - assert not re.match(regex, "tototata") - assert not re.match(regex, "toti.toto") + assert re.match(regex_rule.regex, "toto.tata") + assert re.match(regex_rule.regex, "toto_2351.titi") + assert re.match(regex_rule.regex, "toto_azerty.titi.toto.tata") + assert not re.match(regex_rule.regex, "toto") + assert not re.match(regex_rule.regex, "tototata") + assert not re.match(regex_rule.regex, "toti.toto") + assert not regex_rule.raise_if_found @staticmethod def test_percentage() -> None: @@ -276,11 +281,28 @@ def test_percentage() -> None: module = ModuleWildcard("toto.tata%") # When - regex = PARSER.wildcard_to_regex(module) + regex_rule = PARSER.wildcard_to_regex(module) # Then - assert re.match(regex, "toto.tata") - assert re.match(regex, "toto.tata.titi") - assert re.match(regex, "toto.tata.titi.tutu.tototata.tititutu") - assert not re.match(regex, "toto") - assert not re.match(regex, "toto.tata_123") + assert re.match(regex_rule.regex, "toto.tata") + assert re.match(regex_rule.regex, "toto.tata.titi") + assert re.match(regex_rule.regex, "toto.tata.titi.tutu.tototata.tititutu") + assert not re.match(regex_rule.regex, "toto") + assert not re.match(regex_rule.regex, "toto.tata_123") + assert not regex_rule.raise_if_found + + @staticmethod + def test_not() -> None: + """ + Test not case + """ + # Given + module = ModuleWildcard("~foo") + + # When + regex_rule = PARSER.wildcard_to_regex(module) + + # Then + assert re.match(regex_rule.regex, "foo") + assert not re.match(regex_rule.regex, "bar") + assert regex_rule.raise_if_found