Repository: seddonym/import-linter

Issue:
Forbidden Contracts: Only Forbid Direct Imports
I was wondering if these was a way to configure the Forbidden Contract to only forbid direct imports. Perhaps with a boolean flag. This would be quite useful instead of having to add all the indirect imports to ignore imports directly. In this case, I want to allow indirect imports to a module, but only allow indirect imports. I want to forbid direct imports.

Discussion:

Patch:
diff --git a/AUTHORS.rst b/AUTHORS.rst
index 515c181..b35016c 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -11,4 +11,5 @@ Contributors
 * Anthony Sottile - https://github.com/asottile
 * Łukasz Skarżyński - https://github.com/skarzi
 * Daniel Jurczak - https://github.com/danieljurczak
-* Ben Warren - https://github.com/bwarren2
+* Ben Warren - https://github.com/bwarren
+* Aaron Gokaslan - https://github.com/Skylion007
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index c3608ae..87c558d 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,6 +1,8 @@
 Changelog
 =========
 
+* Add allow_indirect_imports to Forbidden Contract type
+
 1.2 (2020-09-23)
 ----------------
 
diff --git a/docs/contract_types.rst b/docs/contract_types.rst
index ca04020..b11656f 100644
--- a/docs/contract_types.rst
+++ b/docs/contract_types.rst
@@ -66,6 +66,7 @@ Configuration options:
       A list of imports, each in the form ``mypackage.foo.importer -> mypackage.bar.imported``. These imports
       will be ignored: if the import would cause a contract to be broken, adding it to the list will cause the
       contract be kept instead. (Optional.)
+    - ``allow_indirect_imports``: If ``True``, allow indirect imports to forbidden modules. (Optional.)
 
 Independence
 ------------
diff --git a/setup.py b/setup.py
old mode 100644
new mode 100755
diff --git a/src/importlinter/contracts/forbidden.py b/src/importlinter/contracts/forbidden.py
index 6badb2b..9a7a86f 100644
--- a/src/importlinter/contracts/forbidden.py
+++ b/src/importlinter/contracts/forbidden.py
@@ -1,3 +1,5 @@
+from typing import Tuple, cast
+
 from importlinter.application import output
 from importlinter.domain import fields, helpers
 from importlinter.domain.contract import Contract, ContractCheck
@@ -7,16 +9,15 @@ from importlinter.domain.ports.graph import ImportGraph
 class ForbiddenContract(Contract):
     """
     Forbidden contracts check that one set of modules are not imported by another set of modules.
-
     Indirect imports will also be checked.
-
     Configuration options:
-
         - source_modules:    A list of Modules that should not import the forbidden modules.
         - forbidden_modules: A list of Modules that should not be imported by the source modules.
         - ignore_imports:    A set of DirectImports. These imports will be ignored: if the import
                              would cause a contract to be broken, adding it to the set will cause
                              the contract be kept instead. (Optional.)
+        - allow_indirect_imports:  Whether to allow indirect imports to forbidden modules.
+                            "True" or "true" will be treated as True. (Optional.)```
     """
 
     type_name = "forbidden"
@@ -24,6 +25,7 @@ class ForbiddenContract(Contract):
     source_modules = fields.ListField(subfield=fields.ModuleField())
     forbidden_modules = fields.ListField(subfield=fields.ModuleField())
     ignore_imports = fields.SetField(subfield=fields.DirectImportField(), required=False)
+    allow_indirect_imports = fields.StringField(required=False)
 
     def check(self, graph: ImportGraph) -> ContractCheck:
         is_kept = True
@@ -49,9 +51,20 @@ class ForbiddenContract(Contract):
                     "chains": [],
                 }
 
-                chains = graph.find_shortest_chains(
-                    importer=source_module.name, imported=forbidden_module.name
-                )
+                if str(self.allow_indirect_imports).lower() == "true":
+                    chains = {
+                        cast(
+                            Tuple[str, ...],
+                            (str(import_det["importer"]), str(import_det["imported"])),
+                        )
+                        for import_det in graph.get_import_details(
+                            importer=source_module.name, imported=forbidden_module.name
+                        )
+                    }
+                else:
+                    chains = graph.find_shortest_chains(
+                        importer=source_module.name, imported=forbidden_module.name
+                    )
                 if chains:
                     is_kept = False
                     for chain in chains:
@@ -121,4 +134,4 @@ class ForbiddenContract(Contract):
         )
 
     def _graph_was_built_with_externals(self) -> bool:
-        return self.session_options.get("include_external_packages") in ("True", "true")
+        return str(self.session_options.get("include_external_packages")).lower() == "true"


Test patch:
diff --git a/tests/unit/contracts/test_forbidden.py b/tests/unit/contracts/test_forbidden.py
index e69dc93..cc77bb8 100644
--- a/tests/unit/contracts/test_forbidden.py
+++ b/tests/unit/contracts/test_forbidden.py
@@ -138,6 +138,19 @@ class TestForbiddenContract:
         )
         assert contract.check(graph=graph)
 
+    @pytest.mark.parametrize(
+        "allow_indirect_imports, contract_is_kept",
+        ((None, False), ("false", False), ("True", True), ("true", True), ("anything", False)),
+    )
+    def test_allow_indirect_imports(self, allow_indirect_imports, contract_is_kept):
+        graph = self._build_graph()
+        contract = self._build_contract(
+            forbidden_modules=("mypackage.purple"),
+            allow_indirect_imports=allow_indirect_imports,
+        )
+        contract_check = contract.check(graph=graph)
+        assert contract_check.kept == contract_is_kept
+
     def _build_graph(self):
         graph = ImportGraph()
         for module in (
@@ -185,7 +198,11 @@ class TestForbiddenContract:
         return graph
 
     def _build_contract(
-        self, forbidden_modules, ignore_imports=None, include_external_packages=False
+        self,
+        forbidden_modules,
+        ignore_imports=None,
+        include_external_packages=False,
+        allow_indirect_imports=None,
     ):
         session_options = {"root_packages": ["mypackage"]}
         if include_external_packages:
@@ -198,6 +215,7 @@ class TestForbiddenContract:
                 "source_modules": ("mypackage.one", "mypackage.two", "mypackage.three"),
                 "forbidden_modules": forbidden_modules,
                 "ignore_imports": ignore_imports or [],
+                "allow_indirect_imports": allow_indirect_imports,
             },
         )