Repository: zyskarch/pytestarch (language: python)

Issue:
Add language support for "should not import anything" and "should not be imported anywhere"



Discussion / hints:

Fix patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 1623167..c791f41 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -4,6 +4,11 @@ This project uses semantic versioning and follows [keep a changelog](https://kee
 
 
 ## [Unreleased]
+## [1.2.1] -- 2022-10-08
+### Fixed
+- Line separator for rule violation messages
+
+
 ## [1.1.1] -- 2022-10-04
 ### Fixed
 - Line separator for rule violation messages
diff --git a/docs/details.md b/docs/details.md
index 0811185..6abb1f7 100644
--- a/docs/details.md
+++ b/docs/details.md
@@ -136,7 +136,9 @@ Currently, the following markers are supported by PyTestArch:
 * are_submodules_of("Y"): applies to submodules of module named "Y", but not "Y" itself
 
 #### RULE_OBJECT
-same as RULE_SUBJECT
+same as RULE_SUBJECT, with an additional
+
+anything(): can only be used in combination with should_not()
 
 In addition, RULE_OBJECTS can be passed in as a list. The rule is fulfilled if it applies to all rule objects.
 For example, the rule
@@ -186,7 +188,7 @@ modules_that()
 
 Most rules are so close to the English language that a detailed explanation seems unnecessary. An exception might be the
 VERB_MARKER_2 "except". A combination of this VERB_MARKER_2 and every type of VERB_MARKER_1 and IMPORT_TYPE is given below
-as reference (M1, M2 are used as RULE_SUBJECT and RULE_OBJECT respectively; pseudo code for brevity):
+as reference (M1, M2 are used as RULE_SUBJECT and RULE_OBJECT respectively; pseudo-code for brevity):
 
 | Rule | Explanation |
 | ---- | ----------- |
@@ -195,7 +197,13 @@ as reference (M1, M2 are used as RULE_SUBJECT and RULE_OBJECT respectively; pseu
 | M1 should not import except M2| M1 should not import any module other than M2, but does not have to import M2 |
 | M1 should be imported except by M2 | at least one module that isn't M2 should import M1 (M2 can import M1 as well) |
 | M1 should only be imported except by M2 | at least one module that isn't M2 should import M1, and M2 cannot import M1 |
-| M1 should not be imported except by M2 | no module other than M2 should import M1, but M2 does not have to import M1
+| M1 should not be imported except by M2 | no module other than M2 should import M1, but M2 does not have to import M1 |
+
+
+There are two aliases to make rules easier:
+
+* 'M1 should not import anything' is equivalent to: 'M1 should not import anything except itself' (e.g. imports between its submodules are allowed, but no other imports)
+* 'M1 should not be imported by anything' is equivalent to: 'M1 should not be imported by anything except itself' (dito)
 
 
 ## Generating the evaluable architecture representation
@@ -226,4 +234,4 @@ a submodule of it.
 
 ### Module names
 In all rules, modules have to be referred to by their fully qualified name, meaning relative to the `root_path` - not the
-`module_path`! This helps distinguishing between internal and external modules.
+`module_path`! This helps to distinguish between internal and external modules.
diff --git a/pyproject.toml b/pyproject.toml
index 4923b3b..128f472 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "PyTestArch"
-version = "1.1.1"
+version = "1.2.0"
 description = "Test framework for software architecture based on imports between modules"
 authors = ["zyskarch <zyskarch@gmail.com>"]
 maintainers = ["zyskarch <zyskarch@gmail.com>"]


Test patch:
diff --git a/src/pytestarch/query_language/LANGUAGE_DEFINTION.md b/src/pytestarch/query_language/LANGUAGE_DEFINTION.md
index 9b2cb89..d98e9d3 100644
--- a/src/pytestarch/query_language/LANGUAGE_DEFINTION.md
+++ b/src/pytestarch/query_language/LANGUAGE_DEFINTION.md
@@ -18,6 +18,7 @@ IMPORT_TYPE:
 SUBJECT/OBJECT:
     sub_module_of
     name  # complete match
+    anything (only object)
 
 
 M1 should import_from M2
@@ -35,6 +36,21 @@ M1 should not be_imported_from M2
 M1 should not be_imported_from except M2  # M1 does not have to be imported
 
 
+not all combinations are possible with anything:
+M1 should import anything                       x
+M1 should import except anything                x
+M1 should only import anything                  x
+M1 should only import except anything           x
+M1 should not import anything                   o
+M1 should not import except anything            x
+M1 should be_imported by anything               x
+M1 should be_imported by except anything        x
+M1 should only be_imported by anything          x
+M1 should only be_imported except by anything   x
+M1 should not be_imported by anything           o
+M1 should not be_imported except by anything    x
+
+
 
 ## Semantics
 M1 should import M2                 -> edge from M1 to M2
@@ -52,11 +68,19 @@ M1 should be_imported_from except M2        -> any edge from non-M2 to M1
 M1 should only be_imported_from except M2   -> any edge from non-M2 to M1, neg(edge from M2 to M1)
 M1 should not be_imported_from except M2    -> neg(any edge from non-M2 to M1)
 
+M1 should not import anything               -> neg(any edge from M1 to ?)
+M1 should not be_imported by anything       -> neg(any edge from ? to M1)
+
 
 If M2 contains multiple modules, they can be treated separately for "edge", but need to be considered jointly for 
 "any edge".
 
 
+Aliases:
+M1 should not import anything -> M1 should not import anything except itself
+M1 should not be imported by anything -> M1 should not be imported by anything except itself
+
+
 
 Operations: 
     any edge
@@ -66,10 +90,10 @@ Operations:
 
 
 Operation Markers:
-    any         should import except, should only import except | should be except, should only be except
-    edge        should import, should only import               | should be, should only be
-    neg edge    should not import, should only import except    | should not be, should only be except
-    neg any     should not import except, should only import    | should only be, should not be except
+    any         should import except, should only import except             | should be except, should only be except
+    edge        should import, should only import                           | should be, should only be
+    neg edge    should not import, should only import except                | should not be, should only be except
+    neg any     should not import except, should only import                | should only be, should not be except
 
 simplified:
     any         should except, should only except
diff --git a/src/pytestarch/query_language/base_language.py b/src/pytestarch/query_language/base_language.py
index db8a5e8..be5e141 100644
--- a/src/pytestarch/query_language/base_language.py
+++ b/src/pytestarch/query_language/base_language.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 from abc import ABC, abstractmethod
+from dataclasses import dataclass, replace
 from typing import Callable, Generic, List, Optional, Type, TypeVar, Union
 
 from pytestarch.eval_structure.evaluable_architecture import (
@@ -71,7 +72,9 @@ def are_named(self, names: Union[str, List[str]]) -> ModuleSpecificationSuccesso
 
 
 class RuleObject(ModuleSpecification[RuleApplier], ABC):
-    pass
+    @abstractmethod
+    def anything(self) -> ModuleSpecificationSuccessor:
+        pass
 
 
 class RuleSubject(ModuleSpecification[BehaviorSpecification], ABC):
@@ -106,6 +109,18 @@ def modules_that(self) -> RuleSubject:
         pass
 
 
+@dataclass
+class RuleConfiguration:
+    module_to_check: Optional[Module] = None
+    modules_to_check_against: Optional[List[Module]] = None
+    should: bool = False
+    should_only: bool = False
+    should_not: bool = False
+    except_present: bool = False
+    import_: bool = None
+    rule_object_anything: bool = False
+
+
 class Rule(
     DependencySpecification,
     RuleBase,
@@ -122,23 +137,12 @@ def __init__(
         self, rule_matcher_class: Type[RuleMatcher] = DefaultRuleMatcher
     ) -> None:
         self._rule_matcher_class = rule_matcher_class
-
-        self._module_to_check: Optional[Module] = None
-        self._modules_to_check_against: Optional[List[Module]] = None
-
-        self._should = False
-        self._should_only = False
-        self._should_not = False
-
-        self._except_present = False
-
-        self._import = None
-
         self._modules_to_check_to_be_specified_next = None
+        self._configuration = RuleConfiguration()
 
     @property
     def rule_subject(self) -> Optional[Module]:
-        return self._module_to_check
+        return self._configuration.module_to_check
 
     def modules_that(self) -> RuleSubject:
         self._modules_to_check_to_be_specified_next = True
@@ -154,6 +158,12 @@ def are_named(self, names: Union[str, List[str]]) -> BehaviorSpecification:
         self._set_modules(names, lambda name: Module(name=name))
         return self
 
+    def anything(self) -> ModuleSpecificationSuccessor:
+        if self._modules_to_check_to_be_specified_next:
+            raise ImproperlyConfigured('"Anything" can only be used as a rule object.')
+        self._configuration.rule_object_anything = True
+        return self
+
     def _set_modules(
         self,
         module_names: Union[str, List[str]],
@@ -173,47 +183,50 @@ def _set_modules(
             module_names = [module_names]
 
         if self._modules_to_check_to_be_specified_next:
-            self._module_to_check = create_module_fn(module_names)  # type: ignore
+            self._configuration.module_to_check = create_module_fn(module_names)  # type: ignore
         else:
-            self._modules_to_check_against = [create_module_fn(n) for n in module_names]
+            self._configuration.modules_to_check_against = [
+                create_module_fn(n) for n in module_names
+            ]
 
     def should(self) -> DependencySpecification:
-        self._should = True
+        self._configuration.should = True
         return self
 
     def should_only(self) -> DependencySpecification:
-        self._should_only = True
+        self._configuration.should_only = True
         return self
 
     def should_not(self) -> DependencySpecification:
-        self._should_not = True
+        self._configuration.should_not = True
         return self
 
     def import_modules_that(self) -> RuleObject:
-        self._import = True
+        self._configuration.import_ = True
         self._modules_to_check_to_be_specified_next = False
         return self
 
     def be_imported_by_modules_that(self) -> RuleObject:
-        self._import = False
+        self._configuration.import_ = False
         self._modules_to_check_to_be_specified_next = False
         return self
 
     def import_modules_except_modules_that(self) -> RuleObject:
-        self._import = True
-        self._except_present = True
+        self._configuration.import_ = True
+        self._configuration.except_present = True
         self._modules_to_check_to_be_specified_next = False
         return self
 
     def be_imported_by_modules_except_modules_that(self) -> RuleObject:
-        self._import = False
-        self._except_present = True
+        self._configuration.import_ = False
+        self._configuration.except_present = True
         self._modules_to_check_to_be_specified_next = False
         return self
 
     def assert_applies(self, evaluable: EvaluableArchitecture) -> None:
         self._assert_required_configuration_present()
 
+        self._configuration = self._convert_aliases(self._configuration)
         matcher = self._prepare_rule_matcher()
         rule_violations = matcher.match(evaluable)
 
@@ -222,13 +235,15 @@ def assert_applies(self, evaluable: EvaluableArchitecture) -> None:
 
     def _prepare_rule_matcher(self) -> RuleMatcher:
         module_requirement = ModuleRequirement(
-            self._module_to_check, self._modules_to_check_against, self._import
+            self._configuration.module_to_check,
+            self._configuration.modules_to_check_against,
+            self._configuration.import_,
         )
         behavior_requirement = BehaviorRequirement(
-            self._should,
-            self._should_only,
-            self._should_not,
-            self._except_present,
+            self._configuration.should,
+            self._configuration.should_only,
+            self._configuration.should_not,
+            self._configuration.except_present,
         )
 
         return self._rule_matcher_class(module_requirement, behavior_requirement)
@@ -236,28 +251,41 @@ def _prepare_rule_matcher(self) -> RuleMatcher:
     def __str__(self) -> str:
         self._assert_required_configuration_present()
 
-        method_name = f'{"should" if self._should else "should only" if self._should_only else "should not"}'
+        method_name = f'{"should" if self._configuration.should else "should only" if self._configuration.should_only else "should not"}'
 
         subject_prefix = (
-            "Sub modules of " if self._module_to_check.parent_module is not None else ""
+            "Sub modules of "
+            if self._configuration.module_to_check.parent_module is not None
+            else ""
         )
+        if self._configuration.rule_object_anything:
+            object_message = "anything "
+        else:
+            object_message = f'modules that are {"named" if self._configuration.modules_to_check_against[0].name is not None else "sub modules of"} '
+
         return (
-            f'{subject_prefix}"{self._get_module_name(self._module_to_check)}" '
+            f'{subject_prefix}"{self._get_module_name(self._configuration.module_to_check)}" '
             f"{method_name} "
-            f'{"import" if self._import else "be imported by"} '
-            f'{"modules except " if self._except_present else ""}'
-            f'modules that are {"named" if self._modules_to_check_against[0].name is not None else "sub modules of"} '
-            f'"{", ".join(map(lambda module: self._get_module_name(module), self._modules_to_check_against))}".'
+            f'{"import" if self._configuration.import_ else "be imported by"} '
+            f'{"modules except " if self._configuration.except_present else ""}'
+            f"{object_message}"
+            f'"{", ".join(map(lambda module: self._get_module_name(module), self._configuration.modules_to_check_against))}".'
         )
 
     def _get_module_name(self, module: Module) -> str:
         return module.name if module.name is not None else module.parent_module
 
     def _assert_required_configuration_present(self) -> None:
-        behavior_missing = not any([self._should, self._should_only, self._should_not])
-        dependency_missing = self._import is None
-        subject_missing = not self._module_to_check
-        object_missing = not self._modules_to_check_against
+        behavior_missing = not any(
+            [
+                self._configuration.should,
+                self._configuration.should_only,
+                self._configuration.should_not,
+            ]
+        )
+        dependency_missing = self._configuration.import_ is None
+        subject_missing = not self._configuration.module_to_check
+        object_missing = not self._configuration.modules_to_check_against
 
         if any([behavior_missing, dependency_missing, subject_missing, object_missing]):
             subject_message = self._name_or_empty(subject_missing, RuleSubject)
@@ -282,17 +310,39 @@ def _assert_required_configuration_present(self) -> None:
 
             raise ImproperlyConfigured(error_message)
 
+        if (
+            self._configuration.rule_object_anything
+            and not self._configuration.should_not
+        ):
+            raise ImproperlyConfigured(
+                'The "anything" rule object can only be used with "should not".'
+            )
+
     @classmethod
     def _name_or_empty(cls, empty: bool, clz: type) -> str:
         return f"a {clz.__name__}" if empty else ""
 
     def _create_rule_violation_message(self, rule_violations: RuleViolations) -> str:
         message_generator = RuleViolationMessageGenerator(
-            self._module_to_check,
-            self._modules_to_check_against,
-            self._import,
+            self._configuration.module_to_check,
+            self._configuration.modules_to_check_against,
+            self._configuration.import_,
         )
         single_violation_messages = message_generator.create_rule_violation_messages(
             rule_violations
         )
         return "\n".join(single_violation_messages)
+
+    @classmethod
+    def _convert_aliases(cls, configuration: RuleConfiguration) -> RuleConfiguration:
+        if not configuration.rule_object_anything:
+            return configuration
+
+        # should not import anything is equivalent to should not import except itself
+        # should not be imported by anything is equivalent to should not be imported by anything except itself
+        return replace(
+            configuration,
+            rule_object_anything=False,
+            modules_to_check_against=[configuration.module_to_check],
+            except_present=True,
+        )
diff --git a/tests/query_language/test_base_language.py b/tests/query_language/test_base_language.py
index 16a6a9a..fa4426b 100644
--- a/tests/query_language/test_base_language.py
+++ b/tests/query_language/test_base_language.py
@@ -1,11 +1,14 @@
 import pytest
 
-from pytestarch.eval_structure.evaluable_architecture import EvaluableArchitecture
+from pytestarch.eval_structure.evaluable_architecture import (
+    EvaluableArchitecture,
+    Module,
+)
 from pytestarch.eval_structure.evaluable_graph import EvaluableArchitectureGraph
 from pytestarch.eval_structure_impl.networkxgraph import NetworkxGraph
 from pytestarch.exceptions import ImproperlyConfigured
 from pytestarch.importer.import_types import AbsoluteImport
-from pytestarch.query_language.base_language import Rule
+from pytestarch.query_language.base_language import Rule, RuleConfiguration
 
 MODULE_1 = "Module1"
 MODULE_2 = "Module2"
@@ -18,6 +21,8 @@
 SUB_MODULE_OF_1 = "Module1.SubModule1"
 SUB_SUB_MODULE_OF_1 = "Module1.SubModule1.SubModule1"
 
+MODULE_A = Module(name="A")
+
 
 def test_rule_to_str_as_expected() -> None:
     rule = Rule()
@@ -1306,3 +1311,130 @@ def test_should_not_be_imported_except_submodule_submodule_negative(
     )
 
     assert_not_rule_does_not_apply(rule, evaluable1)
+
+
+config_alias_test_cases = [
+    [
+        RuleConfiguration(
+            should_not=True,
+            import_=True,
+            except_present=False,
+            module_to_check=MODULE_A,
+            rule_object_anything=True,
+        ),
+        RuleConfiguration(
+            should_not=True,
+            import_=True,
+            except_present=True,
+            module_to_check=MODULE_A,
+            modules_to_check_against=[MODULE_A],
+            rule_object_anything=False,
+        ),
+    ],
+    [
+        RuleConfiguration(
+            should_not=True,
+            import_=False,
+            except_present=False,
+            module_to_check=MODULE_A,
+            rule_object_anything=True,
+        ),
+        RuleConfiguration(
+            should_not=True,
+            import_=False,
+            except_present=True,
+            module_to_check=MODULE_A,
+            modules_to_check_against=[MODULE_A],
+            rule_object_anything=False,
+        ),
+    ],
+    [
+        rule := RuleConfiguration(
+            should_not=True,
+            import_=True,
+            except_present=False,
+            module_to_check=MODULE_A,
+            rule_object_anything=False,
+        ),
+        rule,
+    ],
+    [
+        rule := RuleConfiguration(
+            should_not=True,
+            import_=False,
+            except_present=False,
+            module_to_check=MODULE_A,
+            rule_object_anything=False,
+        ),
+        rule,
+    ],
+    [
+        rule := RuleConfiguration(
+            should_not=True,
+            import_=True,
+            except_present=False,
+            module_to_check=MODULE_A,
+            rule_object_anything=False,
+        ),
+        rule,
+    ],
+    [
+        rule := RuleConfiguration(
+            should_not=True,
+            import_=False,
+            except_present=False,
+            module_to_check=MODULE_A,
+            rule_object_anything=False,
+        ),
+        rule,
+    ],
+    [
+        rule := RuleConfiguration(
+            should_only=True,
+            import_=True,
+            except_present=False,
+            module_to_check=MODULE_A,
+            rule_object_anything=False,
+        ),
+        rule,
+    ],
+    [
+        rule := RuleConfiguration(
+            should_only=True,
+            import_=False,
+            except_present=False,
+            module_to_check=MODULE_A,
+            rule_object_anything=False,
+        ),
+        rule,
+    ],
+    [
+        rule := RuleConfiguration(
+            should=True,
+            import_=True,
+            except_present=False,
+            module_to_check=MODULE_A,
+            rule_object_anything=False,
+        ),
+        rule,
+    ],
+    [
+        rule := RuleConfiguration(
+            should=True,
+            import_=False,
+            except_present=False,
+            module_to_check=MODULE_A,
+            rule_object_anything=False,
+        ),
+        rule,
+    ],
+]
+
+
+@pytest.mark.parametrize("config_with_alias, expected_config", config_alias_test_cases)
+def test_aliases_converted(
+    config_with_alias: RuleConfiguration,
+    expected_config: RuleConfiguration,
+) -> None:
+    converted_config = Rule._convert_aliases(config_with_alias)
+    assert converted_config == expected_config