Repository: timothycrosley/isort (python)

Issue:
isort is non-deterministic with multiple forced_separate groups
`forced_separate` groups that are specified in a config file can result in non-deterministic sorting. For example:

The following configuration in a setup.cfg, tox.ini etc

```
[isort]
forced_separate:
    separate1
    separate2
```

Should result in:

```
import time

from separate1 import foo

from separate2 import bar
```

But due to the use of `set`  [here](https://github.com/timothycrosley/isort/blob/b5d7cd20696b7deed47cca3f2888c98fe34e5d50/isort/settings.py#L204), sometimes the order is switched to:

```
import time

from separate2 import bar

from separate1 import foo
```

Pull request description:
Maintain order when loading iterable values from config files
Fixes #774.



Patch:
diff --git a/isort/settings.py b/isort/settings.py
index cb047158..8d6be959 100644
--- a/isort/settings.py
+++ b/isort/settings.py
@@ -35,6 +35,7 @@ from collections import namedtuple
 from distutils.util import strtobool
 
 from .pie_slice import lru_cache
+from .utils import difference, union
 
 try:
     import configparser
@@ -217,11 +218,11 @@ def _update_with_config_file(file_path, sections, computed_settings):
             else:
                 existing_data = set(computed_settings.get(access_key, default.get(access_key)))
                 if key.startswith('not_'):
-                    computed_settings[access_key] = list(existing_data.difference(_as_list(value)))
+                    computed_settings[access_key] = difference(existing_data, _as_list(value))
                 elif key.startswith('known_'):
-                    computed_settings[access_key] = list(existing_data.union(_abspaths(cwd, _as_list(value))))
+                    computed_settings[access_key] = union(existing_data, _abspaths(cwd, _as_list(value)))
                 else:
-                    computed_settings[access_key] = list(existing_data.union(_as_list(value)))
+                    computed_settings[access_key] = union(existing_data, _as_list(value))
         elif existing_value_type == bool:
             # Only some configuration formats support native boolean values.
             if not isinstance(value, bool):
diff --git a/isort/utils.py b/isort/utils.py
index 9206c88e..ce4e588e 100644
--- a/isort/utils.py
+++ b/isort/utils.py
@@ -28,3 +28,26 @@ def chdir(path):
         yield
     finally:
         os.chdir(curdir)
+
+
+def union(a, b):
+    """ Return a list of items that are in `a` or `b`
+    """
+    u = []
+    for item in a:
+        if item not in u:
+            u.append(item)
+    for item in b:
+        if item not in u:
+            u.append(item)
+    return u
+
+
+def difference(a, b):
+    """ Return a list of items from `a` that are not in `b`.
+    """
+    d = []
+    for item in a:
+        if item not in b:
+            d.append(item)
+    return d


Test patch:
diff --git a/test_isort.py b/test_isort.py
index ca826a5a..34d19569 100644
--- a/test_isort.py
+++ b/test_isort.py
@@ -2565,6 +2565,31 @@ def test_requirements_finder(tmpdir):
     req_file.remove()
 
 
+def test_forced_separate_is_deterministic_issue_774(tmpdir):
+
+    config_file = tmpdir.join('setup.cfg')
+    config_file.write(
+        "[isort]\n"
+        "forced_separate:\n"
+        "   separate1\n"
+        "   separate2\n"
+        "   separate3\n"
+        "   separate4\n"
+    )
+
+    test_input = ('import time\n'
+                  '\n'
+                  'from separate1 import foo\n'
+                  '\n'
+                  'from separate2 import bar\n'
+                  '\n'
+                  'from separate3 import baz\n'
+                  '\n'
+                  'from separate4 import quux\n')
+
+    assert SortImports(file_contents=test_input, settings_path=config_file.strpath).output == test_input
+
+
 PIPFILE = """
 [[source]]
 url = "https://pypi.org/simple"