Repository: seddonym/import-linter

Issue:
Wildcards for layer containers
Hi! Thanks for the great tool.

I've got a project that has a number of components that each have similar layered structures:

* myproject/
    * components/ 
        * foo
            * router
            * service
            * models
        * bar
            * router
            * service
            * models
       * ... etc ...

I'd love to be able to say `containers = myproject.components.*` rather than having to list out `myproject.components.foo myproject.components.bar` etc. as I suspect it'll be really easy to forget to add a container entry when adding a new component.

Discussion:
@seddonym: Nice idea. Happy to support someone if they want to work on it - reach out and we can take it from there.

Solution patch:
diff --git a/AUTHORS.rst b/AUTHORS.rst
index 8fe811d..6e048dd 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -22,4 +22,5 @@ Contributors
 * piglei - https://github.com/piglei
 * Anton Gruebel - https://github.com/gruebel
 * Peter Byfield - https://github.com/Peter554
-* Fabian Binz - https://github.com/fbinz
\ No newline at end of file
+* Fabian Binz - https://github.com/fbinz
+* Neil Williams - https://github.com/spladug
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 10006c9..0be3f5c 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,6 +1,11 @@
 Changelog
 =========
 
+latest
+------
+
+* Add support for wildcards in layers contract containers.
+
 2.1 (2024-10-8)
 ---------------
 
diff --git a/docs/contract_types.rst b/docs/contract_types.rst
index a11d7fe..a9af7e5 100644
--- a/docs/contract_types.rst
+++ b/docs/contract_types.rst
@@ -116,7 +116,7 @@ way around.
       (``|``) or exclusively colons (``:``) - see :ref:`Multi-item layers`. Does not support :ref:`wildcards`.
     - ``containers``:
       List of the parent modules of the layers, as *absolute names* that you could import, such as
-      ``mypackage.foo``. See :ref:`Containers`. Does not support :ref:`wildcards`. (Optional.)
+      ``mypackage.foo``. See :ref:`Containers`. Supports :ref:`wildcards`. (Optional.)
     - ``ignore_imports``: See :ref:`Shared options`.
     - ``unmatched_ignore_imports_alerting``: See :ref:`Shared options`.
     - ``exhaustive``. If true, check that the contract declares every possible layer in its list of layers to check.
diff --git a/src/importlinter/contracts/layers.py b/src/importlinter/contracts/layers.py
index 5237974..05934ef 100644
--- a/src/importlinter/contracts/layers.py
+++ b/src/importlinter/contracts/layers.py
@@ -10,6 +10,7 @@
 from importlinter.application.contract_utils import AlertLevel
 from importlinter.domain import fields
 from importlinter.domain.contract import Contract, ContractCheck, InvalidContractOptions
+from importlinter.domain.helpers import module_expressions_to_modules
 from importlinter.domain.imports import Module
 
 from ._common import DetailedChain, build_detailed_chain_from_route, render_chain_data
@@ -122,7 +123,7 @@ class LayersContract(Contract):
     type_name = "layers"
 
     layers = fields.ListField(subfield=LayerField())
-    containers = fields.ListField(subfield=fields.StringField(), required=False)
+    containers = fields.ListField(subfield=fields.ModuleExpressionField(), required=False)
     ignore_imports = fields.SetField(subfield=fields.ImportExpressionField(), required=False)
     unmatched_ignore_imports_alerting = fields.EnumField(AlertLevel, default=AlertLevel.ERROR)
     exhaustive = fields.BooleanField(default=False)
@@ -146,16 +147,21 @@ def check(self, graph: grimp.ImportGraph, verbose: bool) -> ContractCheck:
         )
         self.all_module_tails = self._get_all_module_tails_from_layers(self.layers)  # type: ignore
 
+        containers = set()
         if self.containers:
-            self._validate_containers(graph)
+            modules = module_expressions_to_modules(graph, self.containers)  # type: ignore
+            containers = {m.name for m in modules}
+
+        if self.containers:
+            self._validate_containers(graph, containers)
         else:
             self._check_all_containerless_layers_exist(graph)
 
-        undeclared_modules = self._get_undeclared_modules(graph)
+        undeclared_modules = self._get_undeclared_modules(graph, containers)
 
         dependencies = graph.find_illegal_dependencies_for_layers(
             layers=self._grimpify_layers(self.layers),  # type: ignore
-            containers=self.containers,  # type: ignore
+            containers=containers,
         )
         invalid_chains = self._build_invalid_chains(dependencies, graph)
 
@@ -198,11 +204,11 @@ def render_broken_contract(self, check: ContractCheck) -> None:
             )
             output.new_line()
 
-    def _validate_containers(self, graph: grimp.ImportGraph) -> None:
+    def _validate_containers(self, graph: grimp.ImportGraph, containers: set[str]) -> None:
         root_package_names = self.session_options["root_packages"]
         root_packages = tuple(Module(name) for name in root_package_names)
 
-        for container in self.containers:  # type: ignore
+        for container in containers:
             if not any(
                 Module(container).is_in_package(root_package) for root_package in root_packages
             ):
@@ -235,7 +241,7 @@ def _check_all_layers_exist_for_container(
                     f"module {module_name} does not exist."
                 )
 
-    def _get_undeclared_modules(self, graph: grimp.ImportGraph) -> set[str]:
+    def _get_undeclared_modules(self, graph: grimp.ImportGraph, containers: set[str]) -> set[str]:
         if not self.exhaustive:
             return set()
 
@@ -245,7 +251,7 @@ def _get_undeclared_modules(self, graph: grimp.ImportGraph) -> set[str]:
         module_tail_names: set[str] = {module_tail.name for module_tail in self.all_module_tails}
         declared_module_tails = module_tail_names | exhaustive_ignores
 
-        for container in self.containers:  # type: ignore[attr-defined]
+        for container in containers:
             for module in graph.find_children(container):
                 module_tail = module.rpartition(".")[-1]
                 if module_tail not in declared_module_tails:


Test patch:
diff --git a/tests/unit/contracts/test_layers.py b/tests/unit/contracts/test_layers.py
index 4eec579..bb21c3b 100644
--- a/tests/unit/contracts/test_layers.py
+++ b/tests/unit/contracts/test_layers.py
@@ -349,6 +349,111 @@ def _build_contract(self):
         )
 
 
+class TestLayerContractWildcardContainers:
+    def _build_graph(self):
+        graph = ImportGraph()
+        for module in (
+            "mypackage",
+            "mypackage.components.one",
+            "mypackage.components.one.high",
+            "mypackage.components.one.high.green",
+            "mypackage.components.one.high.blue",
+            "mypackage.components.one.high.yellow",
+            "mypackage.components.one.high.yellow.alpha",
+            "mypackage.components.one.medium",
+            "mypackage.components.one.medium.orange",
+            "mypackage.components.one.medium.orange.beta",
+            "mypackage.components.one.medium.red",
+            "mypackage.components.one.low",
+            "mypackage.components.one.low.black",
+            "mypackage.components.one.low.white",
+            "mypackage.components.one.low.white.gamma",
+            "mypackage.components.two",
+            "mypackage.components.two.high",
+            "mypackage.components.two.high.red",
+            "mypackage.components.two.high.red.alpha",
+            "mypackage.components.two.medium",
+            "mypackage.components.two.medium.green",
+            "mypackage.components.two.medium.green.beta",
+            "mypackage.components.two.low",
+            "mypackage.components.two.low.blue",
+            "mypackage.components.two.low.blue.gamma",
+            "mypackage.components.three",
+            "mypackage.components.three.high",
+            "mypackage.components.three.high.white",
+            "mypackage.components.three.medium",
+            "mypackage.components.three.medium.purple",
+            "mypackage.components.three.low",
+            "mypackage.components.three.low.cyan",
+            "mypackage.noncontainer",
+            "mypackage.noncontainer.blue",
+        ):
+            graph.add_module(module)
+
+        # Add some 'legal' imports, each within their separate containers.
+        graph.add_import(
+            importer="mypackage.one.high.green", imported="mypackage.one.medium.orange"
+        )
+        graph.add_import(
+            importer="mypackage.one.high.green", imported="mypackage.one.low.white.gamma"
+        )
+        graph.add_import(
+            importer="mypackage.one.medium.orange", imported="mypackage.one.low.white"
+        )
+
+        graph.add_import(
+            importer="mypackage.two.high.red.alpha", imported="mypackage.two.medium.green.beta"
+        )
+        graph.add_import(
+            importer="mypackage.two.high.red.alpha", imported="mypackage.two.low.blue.gamma"
+        )
+        graph.add_import(
+            importer="mypackage.two.medium.green.beta", imported="mypackage.two.low.blue.gamma"
+        )
+
+        graph.add_import(
+            importer="mypackage.three.high.white", imported="mypackage.three.medium.purple"
+        )
+        graph.add_import(
+            importer="mypackage.three.high.white", imported="mypackage.three.low.cyan"
+        )
+        graph.add_import(
+            importer="mypackage.three.medium.purple", imported="mypackage.three.low.cyan"
+        )
+
+        return graph
+
+    def _build_contract(self):
+        return LayersContract(
+            name="Layer contract",
+            session_options={"root_packages": ["mypackage"]},
+            contract_options={
+                "containers": ["mypackage.components.*"],
+                "layers": ["high", "medium", "low"],
+            },
+        )
+
+    def test_containers_can_use_wildcards(self):
+        contract = self._build_contract()
+        graph = self._build_graph()
+
+        contract_check = contract.check(graph=graph, verbose=False)
+
+        assert contract_check.kept is True
+
+    def test_illegal_grandchild_imports_means_contract_is_broken(self):
+        contract = self._build_contract()
+        graph = self._build_graph()
+        graph.add_import(
+            importer="mypackage.components.two.medium.green.beta",
+            imported="mypackage.components.two.high.red.alpha",
+        )
+
+        contract_check = contract.check(graph=graph, verbose=False)
+
+        assert contract_check.kept is False
+
+
 class TestLayerContractPopulatesMetadata:
     def test_layer_contract_populates_metadata(self):
         graph = self._build_graph_without_imports()