Repository: seddonym/import-linter

Issue:
Duplicate ignored_imports lead to confusing error
If you include an ignored import twice in a contract, you get the following error (as it tries to remove it the second time):

```
The edge mypackage.foo-mypackage.bar not in graph.
```

Comments:

Patch:
diff --git a/AUTHORS.rst b/AUTHORS.rst
index b34e861..b0fa74b 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -9,3 +9,4 @@ Contributors
 ============
 
 * Anthony Sottile - https://github.com/asottile
+* Łukasz Skarżyński - https://github.com/skarzi
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 49ecc2c..3df7df3 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -87,3 +87,5 @@ latest
 ------
 
 * Upgrade Grimp to 1.2.2.
+* Add SetField
+* Use a SetField for ignore_imports options
diff --git a/src/importlinter/contracts/forbidden.py b/src/importlinter/contracts/forbidden.py
index 89a10de..6badb2b 100644
--- a/src/importlinter/contracts/forbidden.py
+++ b/src/importlinter/contracts/forbidden.py
@@ -14,8 +14,8 @@ class ForbiddenContract(Contract):
 
         - 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 list of DirectImports. These imports will be ignored: if the import
-                             would cause a contract to be broken, adding it to the list will cause
+        - 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.)
     """
 
@@ -23,7 +23,7 @@ class ForbiddenContract(Contract):
 
     source_modules = fields.ListField(subfield=fields.ModuleField())
     forbidden_modules = fields.ListField(subfield=fields.ModuleField())
-    ignore_imports = fields.ListField(subfield=fields.DirectImportField(), required=False)
+    ignore_imports = fields.SetField(subfield=fields.DirectImportField(), required=False)
 
     def check(self, graph: ImportGraph) -> ContractCheck:
         is_kept = True
diff --git a/src/importlinter/contracts/independence.py b/src/importlinter/contracts/independence.py
index 71a7ff7..14f206e 100644
--- a/src/importlinter/contracts/independence.py
+++ b/src/importlinter/contracts/independence.py
@@ -16,15 +16,15 @@ class IndependenceContract(Contract):
     Configuration options:
 
         - modules:        A list of Modules that should be independent from each other.
-        - ignore_imports: A list of DirectImports. These imports will be ignored: if the import
-                          would cause a contract to be broken, adding it to the list will cause
+        - 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.)
     """
 
     type_name = "independence"
 
     modules = fields.ListField(subfield=fields.ModuleField())
-    ignore_imports = fields.ListField(subfield=fields.DirectImportField(), required=False)
+    ignore_imports = fields.SetField(subfield=fields.DirectImportField(), required=False)
 
     def check(self, graph: ImportGraph) -> ContractCheck:
         is_kept = True
diff --git a/src/importlinter/contracts/layers.py b/src/importlinter/contracts/layers.py
index 6aa43b5..cf15223 100644
--- a/src/importlinter/contracts/layers.py
+++ b/src/importlinter/contracts/layers.py
@@ -42,8 +42,8 @@ class LayersContract(Contract):
         - layers:         An ordered list of layers. Each layer is the name of a module relative
                           to its parent package. The order is from higher to lower level layers.
         - containers:     A list of the parent Modules of the layers (optional).
-        - ignore_imports: A list of DirectImports. These imports will be ignored: if the import
-                          would cause a contract to be broken, adding it to the list will cause
+        - 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.)
     """
 
@@ -51,7 +51,7 @@ class LayersContract(Contract):
 
     layers = fields.ListField(subfield=LayerField())
     containers = fields.ListField(subfield=fields.StringField(), required=False)
-    ignore_imports = fields.ListField(subfield=fields.DirectImportField(), required=False)
+    ignore_imports = fields.SetField(subfield=fields.DirectImportField(), required=False)
 
     def check(self, graph: ImportGraph) -> ContractCheck:
         is_kept = True
diff --git a/src/importlinter/domain/fields.py b/src/importlinter/domain/fields.py
index 7809113..e226ec9 100644
--- a/src/importlinter/domain/fields.py
+++ b/src/importlinter/domain/fields.py
@@ -1,16 +1,18 @@
 import abc
 import re
-from typing import Any, List, Union
+from typing import Generic, Iterable, List, Set, TypeVar, Union
 
 from importlinter.domain.imports import DirectImport, Module
 
+FieldValue = TypeVar("FieldValue")
+
 
 class ValidationError(Exception):
     def __init__(self, message: str) -> None:
         self.message = message
 
 
-class Field(abc.ABC):
+class Field(Generic[FieldValue], abc.ABC):
     """
     Base class for containers for some data on a Contract.
 
@@ -21,7 +23,7 @@ class Field(abc.ABC):
         self.required = required
 
     @abc.abstractmethod
-    def parse(self, raw_data: Union[str, List[str]]) -> Any:
+    def parse(self, raw_data: Union[str, List[str]]) -> FieldValue:
         """
         Given some raw data supplied by a user, return some clean data.
 
@@ -42,16 +44,13 @@ class StringField(Field):
         return str(raw_data)
 
 
-class ListField(Field):
+class BaseMultipleValueField(Field):
     """
-    A field for multiple values of any type.
+    An abstract field for multiple values of any type.
 
     Arguments:
-        - subfield: An instance of a single-value Field. Each item in the list will be the return
-                    value of this subfield.
-    Usage:
-
-        field = ListField(subfield=AnotherField())
+        - subfield: An instance of a single-value Field. Each item in the iterable will be
+                    the return value of this subfield.
 
     """
 
@@ -59,7 +58,8 @@ class ListField(Field):
         super().__init__(*args, **kwargs)
         self.subfield = subfield
 
-    def parse(self, raw_data: Union[str, List]) -> List[Any]:
+    @abc.abstractmethod
+    def parse(self, raw_data: Union[str, List]) -> Iterable[FieldValue]:
         if isinstance(raw_data, tuple):
             raw_data = list(raw_data)
         if not isinstance(raw_data, list):
@@ -70,6 +70,37 @@ class ListField(Field):
         return clean_list
 
 
+class ListField(BaseMultipleValueField):
+    """
+    A field for multiple values of any type.
+
+    Fields values are returned in list sorted by parsing order.
+
+    Usage:
+
+        field = ListField(subfield=AnotherField())
+    """
+
+    def parse(self, raw_data: Union[str, List]) -> List[FieldValue]:
+        return list(super().parse(raw_data))
+
+
+class SetField(BaseMultipleValueField):
+    """
+    A field for multiple, unique values of any type.
+
+    Fields values are returned inordered in set.
+
+    Usage:
+
+        field = SetField(subfield=AnotherField())
+
+    """
+
+    def parse(self, raw_data: Union[str, List]) -> Set[FieldValue]:
+        return set(super().parse(raw_data))
+
+
 class ModuleField(Field):
     """
     A field for Modules.


Test patch:
diff --git a/tests/unit/contracts/test_forbidden.py b/tests/unit/contracts/test_forbidden.py
index 684d8eb..e69dc93 100644
--- a/tests/unit/contracts/test_forbidden.py
+++ b/tests/unit/contracts/test_forbidden.py
@@ -125,6 +125,19 @@ class TestForbiddenContract:
         ):
             contract.check(graph=graph)
 
+    def test_ignore_imports_tolerates_duplicates(self):
+        graph = self._build_graph()
+        contract = self._build_contract(
+            forbidden_modules=("mypackage.blue", "mypackage.yellow"),
+            ignore_imports=(
+                "mypackage.three -> mypackage.green",
+                "mypackage.utils -> mypackage.purple",
+                "mypackage.three -> mypackage.green",
+            ),
+            include_external_packages=False,
+        )
+        assert contract.check(graph=graph)
+
     def _build_graph(self):
         graph = ImportGraph()
         for module in (
@@ -171,7 +184,9 @@ class TestForbiddenContract:
         )
         return graph
 
-    def _build_contract(self, forbidden_modules, include_external_packages=False):
+    def _build_contract(
+        self, forbidden_modules, ignore_imports=None, include_external_packages=False
+    ):
         session_options = {"root_packages": ["mypackage"]}
         if include_external_packages:
             session_options["include_external_packages"] = "True"
@@ -182,6 +197,7 @@ class TestForbiddenContract:
             contract_options={
                 "source_modules": ("mypackage.one", "mypackage.two", "mypackage.three"),
                 "forbidden_modules": forbidden_modules,
+                "ignore_imports": ignore_imports or [],
             },
         )
 
diff --git a/tests/unit/contracts/test_independence.py b/tests/unit/contracts/test_independence.py
index fefc36d..db3ba26 100644
--- a/tests/unit/contracts/test_independence.py
+++ b/tests/unit/contracts/test_independence.py
@@ -392,3 +392,30 @@ def test_missing_module():
 
     with pytest.raises(ValueError, match=("Module 'mypackage.bar' does not exist.")):
         contract.check(graph=graph)
+
+
+def test_ignore_imports_tolerates_duplicates():
+    graph = ImportGraph()
+    graph.add_module("mypackage")
+    graph.add_import(
+        importer="mypackage.a", imported="mypackage.b", line_number=1, line_contents="-"
+    )
+    graph.add_import(
+        importer="mypackage.a", imported="mypackage.c", line_number=2, line_contents="-"
+    )
+    contract = IndependenceContract(
+        name="Independence contract",
+        session_options={"root_packages": ["mypackage"]},
+        contract_options={
+            "modules": ("mypackage.a", "mypackage.b"),
+            "ignore_imports": [
+                "mypackage.a -> mypackage.b",
+                "mypackage.a -> mypackage.c",
+                "mypackage.a -> mypackage.b",
+            ],
+        },
+    )
+
+    contract_check = contract.check(graph=graph)
+
+    assert contract_check.kept
diff --git a/tests/unit/contracts/test_layers.py b/tests/unit/contracts/test_layers.py
index 36b1086..82231c4 100644
--- a/tests/unit/contracts/test_layers.py
+++ b/tests/unit/contracts/test_layers.py
@@ -765,6 +765,20 @@ class TestIgnoreImports:
         with pytest.raises(MissingImport):
             contract.check(graph=graph)
 
+    def test_ignore_imports_tolerates_duplicates(self):
+        contract = self._build_contract(
+            ignore_imports=[
+                "mypackage.low.black -> mypackage.medium.orange",
+                "mypackage.utils.foo -> mypackage.utils.bar",
+                "mypackage.low.black -> mypackage.medium.orange",
+            ]
+        )
+        graph = self._build_graph()
+
+        contract_check = contract.check(graph=graph)
+
+        assert contract_check.kept
+
     def _build_graph(self):
         graph = ImportGraph()
         for module in (
diff --git a/tests/unit/domain/test_fields.py b/tests/unit/domain/test_fields.py
index 851404a..76883f0 100644
--- a/tests/unit/domain/test_fields.py
+++ b/tests/unit/domain/test_fields.py
@@ -7,6 +7,7 @@ from importlinter.domain.fields import (
     Field,
     ListField,
     ModuleField,
+    SetField,
     StringField,
     ValidationError,
 )
@@ -83,9 +84,23 @@ class TestDirectImportField(BaseFieldTest):
     "raw_data, expected_value",
     (
         (["mypackage.foo", "mypackage.bar"], [Module("mypackage.foo"), Module("mypackage.bar")]),
+        (["mypackage.foo", "mypackage.foo"], [Module("mypackage.foo"), Module("mypackage.foo")]),
         ("singlevalue", [Module("singlevalue")]),
     ),
 )
 class TestListField(BaseFieldTest):
     field_class = ListField
     field_kwargs = dict(subfield=ModuleField())
+
+
+@pytest.mark.parametrize(
+    "raw_data, expected_value",
+    (
+        (["mypackage.foo", "mypackage.bar"], {Module("mypackage.foo"), Module("mypackage.bar")}),
+        (["mypackage.foo", "mypackage.foo"], {Module("mypackage.foo")}),
+        ("singlevalue", {Module("singlevalue")}),
+    ),
+)
+class TestSetField(BaseFieldTest):
+    field_class = SetField
+    field_kwargs = dict(subfield=ModuleField())