Repository: zyskarch/pytestarch (language: python)

Issue:
Modules with X in name should not/only import modules with Y in name



Discussion / hints:

Fix patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index b1ddc08..15b23af 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -4,7 +4,10 @@ This project uses semantic versioning and follows [keep a changelog](https://kee
 
 
 ## [Unreleased]
+
+## [1.3.0] -- 2022-11-28
 ### Added
+- Reference to modules only by part of their names
 - Aliases for plot labels
 - Test generation from PUML component diagrams
 
diff --git a/docs/details.md b/docs/details.md
index 60ff27f..f6100e4 100644
--- a/docs/details.md
+++ b/docs/details.md
@@ -134,6 +134,7 @@ Currently, the following markers are supported by PyTestArch:
 #### RULE_SUBJECT
 * are_named("X"): applies to module named "X" (and also to its submodules)
 * are_submodules_of("Y"): applies to submodules of module named "Y", but not "Y" itself
+* have_name_containing("*Z*"): applies to modules with names containing Z. The syntax is the same as for the file exclusion mechanism
 
 #### RULE_OBJECT
 same as RULE_SUBJECT, with an additional
diff --git a/pyproject.toml b/pyproject.toml
index 1106ca3..d054b8d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "PyTestArch"
-version = "1.2.1"
+version = "1.3.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/eval_structure/evaluable_architecture.py b/src/pytestarch/eval_structure/evaluable_architecture.py
index 1b2152c..1b8ab49 100644
--- a/src/pytestarch/eval_structure/evaluable_architecture.py
+++ b/src/pytestarch/eval_structure/evaluable_architecture.py
@@ -12,10 +12,12 @@ class Module:
     Attributes:
         name: full name of the module
         parent_module: full name of the parent module
+        partial_match: if True, the name may only represent a single part of the actual module name
     """
 
     name: Optional[str] = None
     parent_module: Optional[str] = None
+    partial_match: bool = False
 
 
 StrictDependency = Tuple[Module, Module]
@@ -43,7 +45,8 @@ def get_dependencies(
             dependent_upon: Module
 
         Returns:
-            Importer and importee per pair of dependent and dependent_upon module if there are any that are sub modules of dependent and dependent_upon respectively.
+            Importer and importee per pair of dependent and dependent_upon module if there are any
+            that are sub modules of dependent and dependent_upon respectively.
         """
         raise NotImplementedError()
 
diff --git a/src/pytestarch/eval_structure/evaluable_graph.py b/src/pytestarch/eval_structure/evaluable_graph.py
index fb6f064..59c8331 100644
--- a/src/pytestarch/eval_structure/evaluable_graph.py
+++ b/src/pytestarch/eval_structure/evaluable_graph.py
@@ -1,8 +1,9 @@
 """Base class for different graph implementations of an evaluable structure. Delegates direct access to graph nodes
 and edges to its subclasses in a template pattern.
 """
+from collections import defaultdict
 from itertools import product
-from typing import Any, List, Set, Union
+from typing import Any, List, Set, Union, Iterable
 
 from pytestarch.eval_structure.evaluable_architecture import (
     EvaluableArchitecture,
@@ -12,6 +13,9 @@
     StrictDependency,
 )
 from pytestarch.eval_structure.evaluable_structures import AbstractGraph, AbstractNode
+from pytestarch.exceptions import ImpossibleMatch
+
+ALL_MARKER = "*"
 
 
 class EvaluableArchitectureGraph(EvaluableArchitecture):
@@ -30,6 +34,10 @@ def get_dependencies(
         dependents = self._listify(dependents)
         dependent_upons = self._listify(dependent_upons)
 
+        dependents, dependent_upons = self._convert_to_full_name_matches(
+            [dependents, dependent_upons]
+        )
+
         for dependent, dependent_upon in product(dependents, dependent_upons):
             dependency = self._get_dependency_between_modules(dependent, dependent_upon)
             result[(dependent, dependent_upon)] = dependency
@@ -84,6 +92,10 @@ def any_dependencies_to_modules_other_than(
         dependents = self._listify(dependents)
         dependent_upons = self._listify(dependent_upons)
 
+        dependents, dependent_upons = self._convert_to_full_name_matches(
+            [dependents, dependent_upons]
+        )
+
         for dependent in dependents:
             dependencies = self._any_dependency_to_module_other_than(
                 dependent, dependent_upons
@@ -98,6 +110,7 @@ def _listify(self, module: Union[Module, List[Module]]) -> List[Module]:
     def _any_dependency_to_module_other_than(  # noqa: C901
         self, dependent: Module, dependent_upons: List[Module]
     ) -> List[StrictDependency]:
+        # nodes to exclude are nodes that once reached will not have their submodules analysed next
         nodes_to_exclude = set()
         for dependent_upon in dependent_upons:
             if dependent_upon == dependent:
@@ -105,18 +118,29 @@ def _any_dependency_to_module_other_than(  # noqa: C901
             nodes_to_exclude.update(self._get_all_submodules_of(dependent_upon))
 
         nodes_fulfilling_criteria = []
+
+        # nodes that count as not fulfilling the criterion are nodes that would technically fulfill the criterion,
+        # but have to be excluded for consistency reasons. For example, if the dependent is called A and has two
+        # submodules A.X and A.Y. If A.X and A.Y import each other, this would be registered as fulfilling the
+        # requirement of a module other than the dependent upons (assuming that A.X and A.Y are not in this list).
+        # however, this is not really an import of a module outside A, which is what we are actually looking for
+
         # should submodules of the dependent module import each other, this does not count as a dependency
         nodes_that_do_not_fulfill_criterion = self._get_all_submodules_of(dependent)
 
         if dependent.parent_module is not None:
             # if the parent module is set and has a dependency other than dependent upon, it should not count as only
-            # true submodules should be considered
+            # the dependent and its submodules should be considered
+            # Example: if we are looking for imports by A.X, we do not care if A itself imports something
+            # (but we do care if A.X.M (submodule of A.X) imports something, as this is part of A.X)
             nodes_to_exclude.add(dependent.parent_module)
 
         for dependent_upon in dependent_upons:
             if dependent_upon.parent_module is not None:
                 # if there is a dependency to the parent module of dependent upon, this counts, as only dependencies to
                 # the true submodules are excluded
+                # reason: if something imports B, then it imports something that is not B.X (and B.X is a dependent_upon module)
+                # (but we do not care if it imports B.X.M, as this is part of B.X)
                 nodes_to_exclude.remove(dependent_upon.parent_module)
 
         nodes_to_check = list(nodes_that_do_not_fulfill_criterion)
@@ -160,6 +184,10 @@ def any_other_dependencies_to_modules_than(
         dependents = self._listify(dependents)
         dependent_upons = self._listify(dependent_upons)
 
+        dependents, dependent_upons = self._convert_to_full_name_matches(
+            [dependents, dependent_upons]
+        )
+
         for dependent_upon in dependent_upons:
             dependencies = self._any_other_dependency_to_module_than(
                 dependents, dependent_upon
@@ -173,6 +201,8 @@ def _any_other_dependency_to_module_than(  # noqa: C901
     ) -> List[StrictDependency]:
         # submodules of the dependent upon module do not count as an import that is not the dependent upon module
         # submodules of and including the dependent do not count as allowed imports
+
+        # nodes to exclude are nodes that once reached will not have their submodules analysed next
         nodes_to_exclude = set()
         for dependent in dependents:
             if dependent == dependent_upon:
@@ -180,14 +210,22 @@ def _any_other_dependency_to_module_than(  # noqa: C901
             nodes_to_exclude.update(self._get_all_submodules_of(dependent))
 
         nodes_fulfilling_criteria = []
-        # submodules of the dependent upon do not count as allowed imports if they should import each other
+
+        # nodes that count as not fulfilling the criterion are nodes that would technically fulfill the criterion,
+        # but have to be excluded for consistency reasons. For example, if the dependent upon is called A and has two
+        # submodules A.X and A.Y. If A.X and A.Y import each other, this would be registered as fulfilling the
+        # requirement of a module other than the dependents (assuming that A.X and A.Y are not in this list).
+        # however, this is not really an import by a module outside A, which is what we are actually looking for
+
+        # submodules of the dependent upon do not count as allowed imports e.g. if they import each other
         nodes_that_count_as_not_fulfilling_criterion = self._get_all_submodules_of(
             dependent_upon
         )
 
         if dependent_upon.parent_module is not None:
-            # if the dependent upon module is defined via a parent module, this is not included in the list of modules
-            # that do not fulfill the criteria - only its true submodules
+            # if the dependent upon module is defined via a parent module, this parent module is not included in the
+            # list of modules that do not fulfill the criteria - only its true submodules are
+            # reason: there is a dependency from the parent module to the child module, but this is not an import
             nodes_that_count_as_not_fulfilling_criterion.remove(
                 dependent_upon.parent_module
             )
@@ -197,6 +235,8 @@ def _any_other_dependency_to_module_than(  # noqa: C901
         for dependent in dependents:
             if dependent.parent_module is not None:
                 # if the dependent module is defined via a parent module, this parent module counts as an allowed import
+                # reason: we are looking for dependencies other than module A.X. Parent module A is not (only) A.X, so it counts
+                # (submodule A.X.M does not count, as it is not anything else but A.X)
                 nodes_to_exclude.remove(dependent.parent_module)
 
         checked_nodes = set()
@@ -227,7 +267,8 @@ def _any_other_dependency_to_module_than(  # noqa: C901
 
         return nodes_fulfilling_criteria  # type: ignore
 
-    def _to_modules(self, nodes: List[AbstractNode]) -> List[Module]:
+    @classmethod
+    def _to_modules(cls, nodes: List[AbstractNode]) -> List[Module]:
         return list(map(lambda node: Module(name=node), nodes))
 
     def _get_all_submodules_of(self, module: Module) -> Set[AbstractNode]:
@@ -266,6 +307,110 @@ def _get_all_submodules_of(self, module: Module) -> Set[AbstractNode]:
     def visualize(self, **kwargs: Any) -> None:
         self._graph.draw(**kwargs)
 
-    def _get_excluded_nodes(self, modules: List[Module]) -> List[AbstractNode]:
+    @classmethod
+    def _get_excluded_nodes(cls, modules: List[Module]) -> List[AbstractNode]:
         result = [module.parent_module for module in modules]
         return [node for node in result if node is not None]
+
+    def _convert_to_full_name_matches(
+        self, module_groups: List[List[Module]]
+    ) -> List[List[Module]]:
+        """For each module requiring a partial name match, find all modules that match.
+        Modules without the partial name match requirement are appended to the output as-is.
+
+        To avoid iterating over all modules in the graph multiple times, multiple different lists of modules to match
+        can be passed in. A list of identical length will be returned: Each module in input list with idx n will
+        also be present (either directly, if no partial match; or replaced by its match) in list with idx n in the
+        output.
+        """
+
+        modules_by_group = {idx: group for idx, group in enumerate(module_groups)}
+
+        (
+            partial_match_module_name_by_group,
+            result_by_group,
+        ) = self._split_into_complete_modules_and_partial_matches(modules_by_group)
+
+        if not any(partial_match_module_name_by_group.values()):
+            return self._convert_dict_values_to_list_sorted_by_keys(result_by_group)
+
+        matched_module_names_by_group = defaultdict(set)
+
+        never_matched = {
+            module
+            for modules in partial_match_module_name_by_group.values()
+            for module in modules
+        }
+
+        for node in self._graph.nodes:
+            for group, matches in partial_match_module_name_by_group.items():
+                matched = False
+
+                for match in matches:
+                    if matched and match not in never_matched:
+                        continue
+
+                    if self._partial_name_match(match, node):
+                        matched_module_names_by_group[group].add(node)
+
+                        if match in never_matched:
+                            never_matched.remove(match)
+
+        if never_matched:
+            raise ImpossibleMatch(
+                f'No modules found that match: {", ".join(never_matched)}'
+            )
+
+        for group, matched_module_names in matched_module_names_by_group.items():
+            result_by_group[group].extend(
+                self._convert_to_modules(matched_module_names)
+            )
+
+        return self._convert_dict_values_to_list_sorted_by_keys(result_by_group)
+
+    def _split_into_complete_modules_and_partial_matches(
+        self,
+        modules_by_group: dict[int, List[Any]],
+    ) -> tuple[dict[int, List[AbstractNode]], dict[int, List[Module]]]:
+        result_by_group: dict[int, List[Module]] = defaultdict(list)
+        partial_match_module_name_by_group: dict[int, List[AbstractNode]] = {}
+
+        for group, modules in modules_by_group.items():
+            partial_match_module_names = []
+            result = []
+
+            for node in modules:
+                partial_match_module_names.append(
+                    self._get_node(node)
+                ) if node.partial_match else result.append(node)
+
+            result_by_group[group] = result
+            partial_match_module_name_by_group[group] = partial_match_module_names
+
+        return partial_match_module_name_by_group, result_by_group
+
+    @classmethod
+    def _convert_dict_values_to_list_sorted_by_keys(
+        cls, d: dict[Any, Iterable[Any]]
+    ) -> List[List[Any]]:
+        return [group_and_values[1] for group_and_values in sorted(d.items())]
+
+    @classmethod
+    def _partial_name_match(cls, module_to_match: str, name: AbstractNode) -> bool:
+        starts_with_all_marker = module_to_match.startswith(ALL_MARKER)
+        ends_with_al_marker = module_to_match.endswith(ALL_MARKER)
+
+        if starts_with_all_marker and ends_with_al_marker:
+            return module_to_match[1:-1] in name
+
+        if starts_with_all_marker:
+            return name.endswith(module_to_match[1:])
+
+        if ends_with_al_marker:
+            return name.startswith(module_to_match[:-1])
+
+        return module_to_match == name
+
+    @classmethod
+    def _convert_to_modules(cls, module_names: Set[str]) -> List[Module]:
+        return [Module(name=name) for name in module_names]
diff --git a/src/pytestarch/eval_structure/evaluable_structures.py b/src/pytestarch/eval_structure/evaluable_structures.py
index 26dde60..6219841 100644
--- a/src/pytestarch/eval_structure/evaluable_structures.py
+++ b/src/pytestarch/eval_structure/evaluable_structures.py
@@ -18,3 +18,8 @@ def parent_child_relationship(
     @abstractmethod
     def direct_predecessor_nodes(self, node: AbstractNode) -> List[AbstractNode]:
         raise NotImplementedError()
+
+    @property
+    @abstractmethod
+    def nodes(self) -> List[AbstractNode]:
+        raise NotImplementedError()
diff --git a/src/pytestarch/eval_structure_impl/networkxgraph.py b/src/pytestarch/eval_structure_impl/networkxgraph.py
index 9f96f2b..c4dd4cb 100644
--- a/src/pytestarch/eval_structure_impl/networkxgraph.py
+++ b/src/pytestarch/eval_structure_impl/networkxgraph.py
@@ -46,6 +46,7 @@ def __init__(
         self._level_limit = level_limit
 
         self._initialise()
+        nx.freeze(self._graph)
 
     def _initialise(self) -> None:
         """Constructs a graph from all modules and their imports."""
diff --git a/src/pytestarch/exceptions.py b/src/pytestarch/exceptions.py
index c6415e0..18de964 100644
--- a/src/pytestarch/exceptions.py
+++ b/src/pytestarch/exceptions.py
@@ -8,3 +8,7 @@ class ParsingError(Exception):
 
 class PumlParsingError(ParsingError):
     pass
+
+
+class ImpossibleMatch(Exception):
+    pass
diff --git a/src/pytestarch/importer/file_filter.py b/src/pytestarch/importer/file_filter.py
index 343825b..ca4ba13 100644
--- a/src/pytestarch/importer/file_filter.py
+++ b/src/pytestarch/importer/file_filter.py
@@ -22,11 +22,14 @@ def __init__(self, config: Config) -> None:
         self._excluded_infixes = set()
 
         for dir in config.excluded_directories:
-            if dir.startswith(ALL_MARKER) and dir.endswith(ALL_MARKER):
+            starts_with_all_marker = dir.startswith(ALL_MARKER)
+            ends_with_all_marker = dir.endswith(ALL_MARKER)
+
+            if starts_with_all_marker and ends_with_all_marker:
                 self._excluded_infixes.add(dir[1:-1])
-            elif dir.startswith(ALL_MARKER):
+            elif starts_with_all_marker:
                 self._excluded_suffixes.add(dir[1:])
-            elif dir.endswith(ALL_MARKER):
+            elif ends_with_all_marker:
                 self._excluded_prefixes.add(dir[:-1])
             else:
                 self._excluded_dirs.add(dir)
diff --git a/src/pytestarch/query_language/LANGUAGE_DEFINTION.md b/src/pytestarch/query_language/LANGUAGE_DEFINTION.md
index d98e9d3..80f172b 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
+    partial_name  # partial match
     anything (only object)
 
 
diff --git a/src/pytestarch/query_language/base_language.py b/src/pytestarch/query_language/base_language.py
index 7fccb22..cb4b80b 100644
--- a/src/pytestarch/query_language/base_language.py
+++ b/src/pytestarch/query_language/base_language.py
@@ -90,6 +90,12 @@ def are_sub_modules_of(
     def are_named(self, names: Union[str, List[str]]) -> ModuleSpecificationSuccessor:
         pass
 
+    @abstractmethod
+    def have_name_containing(
+        self, partial_name: Union[str, List[str]]
+    ) -> ModuleSpecificationSuccessor:
+        pass
+
 
 class RuleObject(ModuleSpecification[RuleApplier], ABC):
     pass
diff --git a/src/pytestarch/query_language/rule.py b/src/pytestarch/query_language/rule.py
index 11ca9e7..3fd762a 100644
--- a/src/pytestarch/query_language/rule.py
+++ b/src/pytestarch/query_language/rule.py
@@ -73,6 +73,14 @@ def are_named(self, names: Union[str, List[str]]) -> BehaviorSpecification:
         self._set_modules(names, lambda name: Module(name=name))
         return self
 
+    def have_name_containing(
+        self, partial_names: Union[str, List[str]]
+    ) -> BehaviorSpecification:
+        self._set_modules(
+            partial_names, lambda name: Module(name=name, partial_match=True)
+        )
+        return self
+
     def _set_modules(
         self,
         module_names: Union[str, List[str]],
diff --git a/src/pytestarch/query_language/rule_matcher.py b/src/pytestarch/query_language/rule_matcher.py
index 161edb6..995a04c 100644
--- a/src/pytestarch/query_language/rule_matcher.py
+++ b/src/pytestarch/query_language/rule_matcher.py
@@ -126,9 +126,6 @@ def generate_rule_violation(
         self,
         strict_dependencies: Optional[StrictDependenciesByBaseModules],
         lax_dependencies: Optional[LaxDependenciesByBaseModule],
-        import_rule: bool,
-        left_hand_side_module: Union[Module, List[Module]],
-        right_hand_side_module: Union[Module, List[Module]],
     ) -> RuleViolations:
         """Translate from the detected types of dependencies back to which behavior and dependency requirements are violated by them.
         Args:
@@ -209,9 +206,6 @@ def match(self, evaluable: EvaluableArchitecture) -> RuleViolations:
         return self._behavior_requirement.generate_rule_violation(
             strict_dependencies,
             lax_dependencies,
-            not self._module_requirement.left_hand_module_has_specifier,
-            self._module_requirement.left_hand_modules,
-            self._module_requirement.right_hand_modules,
         )
 
     def _get_lax_dependencies(
diff --git a/tests/eval_structure/test_evaluable_graph.py b/tests/eval_structure/test_evaluable_graph.py
index 30b6174..e6386b2 100644
--- a/tests/eval_structure/test_evaluable_graph.py
+++ b/tests/eval_structure/test_evaluable_graph.py
@@ -9,6 +9,7 @@
 )
 from pytestarch.eval_structure.evaluable_graph import EvaluableArchitectureGraph
 from pytestarch.eval_structure_impl.networkxgraph import NetworkxGraph
+from pytestarch.exceptions import ImpossibleMatch
 from pytestarch.importer.import_types import AbsoluteImport
 
 MODULE_1 = "Module1"
@@ -34,7 +35,7 @@
 
 
 @pytest.fixture(scope="module")
-def evaluable() -> EvaluableArchitecture:
+def evaluable() -> EvaluableArchitectureGraph:
     all_modules = [MODULE_1, MODULE_2, MODULE_3, MODULE_4, SUB_MODULE_OF_2, MODULE_6]
     imports = [
         AbsoluteImport(MODULE_1, MODULE_2),
@@ -49,7 +50,7 @@ def evaluable() -> EvaluableArchitecture:
 
 
 @pytest.fixture(scope="module")
-def submodule_evaluable() -> EvaluableArchitecture:
+def submodule_evaluable() -> EvaluableArchitectureGraph:
     all_modules = [MODULE_A, MODULE_B, MODULE_C, MODULE_D, MODULE_E, MODULE_F]
     imports = [
         AbsoluteImport(MODULE_F, MODULE_C),
@@ -783,8 +784,8 @@ def test_not_other_to_module_than_between_named_modules(
 @pytest.mark.parametrize(
     "imports",
     [
-        # [AbsoluteImport(MODULE_1, SUB_MODULE_OF_2)],
-        # [AbsoluteImport(SUB_MODULE_OF_1, SUB_MODULE_OF_2)],
+        [AbsoluteImport(MODULE_1, SUB_MODULE_OF_2)],
+        [AbsoluteImport(SUB_MODULE_OF_1, SUB_MODULE_OF_2)],
         [AbsoluteImport(MODULE_3, MODULE_2)],
     ],
 )
@@ -914,3 +915,95 @@ def test_when_module_name_in_alias_spec_is_not_found_an_error_is_raised(
 
     with pytest.raises(KeyError, match=non_existent_module):
         evaluable.visualize(aliases=aliases)
+
+
+MODULE = "test.module.submodule.class"
+
+
+partial_match_test_cases = [
+    pytest.param("test.module.submodule.class", True, id="complete match - match"),
+    pytest.param("test.module.submodule.clasz", False, id="complete match - no match"),
+    pytest.param("*.class", True, id="end match - match"),
+    pytest.param("*.clasz", False, id="end match - no match"),
+    pytest.param("test.*", True, id="start match - match"),
+    pytest.param("test,*", False, id="start match - no match"),
+    pytest.param("*le.submo*", True, id="inner match - match"),
+    pytest.param("les.submo*", False, id="inner match - no match"),
+]
+
+
+@pytest.mark.parametrize("match, expected_result", partial_match_test_cases)
+def test_partial_match(match: str, expected_result: bool) -> None:
+    assert (
+        EvaluableArchitectureGraph._partial_name_match(match, MODULE) == expected_result
+    )
+
+
+def test_module_matches(submodule_evaluable: EvaluableArchitectureGraph) -> None:
+    module_matches = [
+        Module(name="*D", partial_match=True),
+        Module(name="*A*", partial_match=True),
+    ]
+
+    calculated_matches = submodule_evaluable._convert_to_full_name_matches(
+        [module_matches]
+    )
+
+    assert len(calculated_matches) == 1
+    assert len(calculated_matches[0]) == 3
+
+    assert Module(name=MODULE_A) in calculated_matches[0]
+    assert Module(name=MODULE_B) in calculated_matches[0]
+    assert Module(name=MODULE_D) in calculated_matches[0]
+
+
+def test_non_partial_module_does_not_match_anything_but_itself(
+    submodule_evaluable: EvaluableArchitectureGraph,
+) -> None:
+    module_matches = [Module(name="*.*")]
+
+    calculated_matches = submodule_evaluable._convert_to_full_name_matches(
+        [module_matches]
+    )
+
+    assert len(calculated_matches) == 1
+    assert len(calculated_matches[0]) == 1
+
+    assert Module(name="*.*") in calculated_matches[0]
+
+
+def test_never_matched_match_raises_error(
+    evaluable: EvaluableArchitectureGraph,
+) -> None:
+    module_matches = [Module(name="I will not match", partial_match=True)]
+
+    with pytest.raises(
+        ImpossibleMatch, match="No modules found that match: I will not match"
+    ):
+        evaluable._convert_to_full_name_matches([module_matches])
+
+
+def test_module_matches_reported_separately_by_group(
+    submodule_evaluable: EvaluableArchitectureGraph,
+) -> None:
+    module_matches_1 = [
+        Module(name="*D", partial_match=True),
+    ]
+
+    module_matches_2 = [
+        Module(name="*A*", partial_match=True),
+    ]
+
+    calculated_matches = submodule_evaluable._convert_to_full_name_matches(
+        [module_matches_1, module_matches_2]
+    )
+
+    assert len(calculated_matches) == 2
+
+    assert len(calculated_matches[0]) == 1
+    assert len(calculated_matches[1]) == 3
+
+    assert Module(name=MODULE_D) in calculated_matches[0]
+    assert Module(name=MODULE_A) in calculated_matches[1]
+    assert Module(name=MODULE_B) in calculated_matches[1]
+    assert Module(name=MODULE_D) in calculated_matches[1]
diff --git a/tests/test_integration.py b/tests/test_integration.py
index 58e6ce2..e165063 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -1519,3 +1519,121 @@ def test_rule_violated_with_multiple_rule_objects_raises_proper_error_message(
 ) -> None:
     with pytest.raises(AssertionError, match=expected_error_message):
         rule.assert_applies(graph_based_on_string_module_names)
+
+
+partial_name_match_test_cases = [
+    pytest.param(
+        Rule()
+        .modules_that()
+        .have_name_containing("*moduleC*")
+        .should()
+        .import_modules_that()
+        .are_sub_modules_of([A, B]),
+        True,
+        id="named should import submodule - partial name match subject",
+    ),
+    pytest.param(
+        Rule()
+        .modules_that()
+        .are_sub_modules_of(A)
+        .should_only()
+        .import_modules_that()
+        .have_name_containing("*leB*"),
+        True,
+        id="submodule should only import named - partial name match object",
+    ),
+    pytest.param(
+        Rule()
+        .modules_that()
+        .have_name_containing("*moduleB")
+        .should_not()
+        .import_modules_that()
+        .are_named([A, C]),
+        True,
+        id="named should not import named - partial name match subject",
+    ),
+    pytest.param(
+        Rule()
+        .modules_that()
+        .have_name_containing("src.moduleA*")
+        .should_only()
+        .import_modules_except_modules_that()
+        .are_sub_modules_of([C, B]),
+        True,
+        id="named should only import except submodule - forbidden import - partial name match subject",
+    ),
+    pytest.param(
+        Rule()
+        .modules_that()
+        .are_sub_modules_of(C)
+        .should_only()
+        .import_modules_except_modules_that()
+        .have_name_containing("*moduleB*"),
+        True,
+        id="submodule should only import except named - no import - partial name match object",
+    ),
+    pytest.param(
+        Rule()
+        .modules_that()
+        .have_name_containing("*duleA*")
+        .should_not()
+        .import_modules_except_modules_that()
+        .are_named([B, B2]),
+        True,
+        id="named should not import except named - partial name match subject",
+    ),
+    pytest.param(
+        Rule()
+        .modules_that()
+        .have_name_containing("*moduleA*")
+        .should()
+        .be_imported_by_modules_that()
+        .are_sub_modules_of([C, FILE_C]),
+        True,
+        id="named should be imported by submodule - partial name match subject",
+    ),
+    pytest.param(
+        Rule()
+        .modules_that()
+        .are_sub_modules_of(C)
+        .should_only()
+        .be_imported_by_modules_that()
+        .have_name_containing(["*leB"]),
+        True,
+        id="submodule should only be imported by named - partial name match object",
+    ),
+    pytest.param(
+        Rule()
+        .modules_that()
+        .have_name_containing("*moduleC*")
+        .should_not()
+        .be_imported_by_modules_that()
+        .are_named([A, FILE_A2]),
+        True,
+        id="named should not be imported by named - partial name match subject",
+    ),
+    pytest.param(
+        Rule()
+        .modules_that()
+        .are_sub_modules_of(A11)
+        .should_only()
+        .be_imported_by_modules_except_modules_that()
+        .have_name_containing(["*fileB2"]),
+        True,
+        id="submodule should only be imported by except named - forbidden import - partial name match object",
+    ),
+]
+
+
+@pytest.mark.parametrize("rule, violation", partial_name_match_test_cases)
+def test_rule_violation_correctly_detected_for_partial_name_matches(
+    rule: Rule,
+    violation: bool,
+    graph_based_on_string_module_names: EvaluableArchitecture,
+) -> None:
+    if violation:
+        with pytest.raises(AssertionError):
+            rule.assert_applies(graph_based_on_string_module_names)
+    else:
+        rule.assert_applies(graph_based_on_string_module_names)
+        # no exception