Repository: timothycrosley/isort
Pull request #867 (2019-03-01 21:36:46)

Add -rr/--reverse-relative option (issue #417)
This new option changes the sort order of relative imports. The new default
sort order is from furthest to closest (most dots to least dots):

    from ... import a
    from ...foo import x
    from .. import b
    from ..bar import y
    from . import c
    from .baz import z

With the new -rr/--reverse-relative option, the order of the relative
import *levels* (inter-order) is reversed, but the ordering within each
relative import level (intra-order) remains the same (i.e. normal lexical
order).

    from . import c
    from .baz import z
    from .. import b
    from ..bar import y
    from ... import a
    from ...foo import x

Signed-off-by: Peter Grayson <pete@jpgrayson.net>


Patch:
diff --git a/.editorconfig b/.editorconfig
deleted file mode 100644
index 094bc546..00000000
--- a/.editorconfig
+++ /dev/null
@@ -1,20 +0,0 @@
-root = true
-
-[*.py]
-max_line_length = 120
-indent_style = space
-indent_size = 4
-known_first_party = isort
-known_third_party = kate
-ignore_frosted_errors = E103
-skip = build,.tox,venv
-balanced_wrapping = true
-not_skip = __init__.py
-
-[*.{rst,ini}]
-indent_style = space
-indent_size = 4
-
-[*.yml]
-indent_style = space
-indent_size = 2
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6635a5af..e72551a6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,9 +12,6 @@ Internal:
 Planned:
   - profile support for common project types (black, django, google, etc)
 
-### 4.3.9 - Feburary 25, 2019 - hot fix release
-- Fixed a bug that led to an incompatibility with black: #831
-
 ### 4.3.8 - Feburary 25, 2019 - hot fix release
 - Fixed a bug that led to the recursive option not always been available from the command line.
 
diff --git a/README.rst b/README.rst
index 02598b22..baf71618 100644
--- a/README.rst
+++ b/README.rst
@@ -329,7 +329,7 @@ past the line_length limit and has 6 possible settings:
 
 In Mode 5 isort leaves a single extra space to maintain consistency of output when a comma is added at the end.
 Mode 6 is the same - except that no extra space is maintained leading to the possibility of lines one character longer.
-You can enforce a trailing comma by using this in conjunction with ``-tc`` or ``include_trailing_comma: True``.
+You can enforce a trailing comma by using this in conjunction with `-tc` or `trailing_comma: True`.
 
 .. code-block:: python
 
diff --git a/isort/__init__.py b/isort/__init__.py
index 63034de6..3553b811 100644
--- a/isort/__init__.py
+++ b/isort/__init__.py
@@ -22,4 +22,4 @@ OTHER DEALINGS IN THE SOFTWARE.
 from . import settings  # noqa: F401
 from .isort import SortImports  # noqa: F401
 
-__version__ = "4.3.9"
+__version__ = "4.3.8"
diff --git a/isort/finders.py b/isort/finders.py
index ccf6caac..e21ef447 100644
--- a/isort/finders.py
+++ b/isort/finders.py
@@ -8,7 +8,6 @@ import sys
 import sysconfig
 from abc import ABCMeta, abstractmethod
 from fnmatch import fnmatch
-from functools import lru_cache
 from glob import glob
 from typing import Any, Dict, Iterable, Iterator, List, Mapping, Optional, Pattern, Sequence, Tuple, Type
 
@@ -35,6 +34,7 @@ try:
 except ImportError:
     Pipfile = None
 
+
 KNOWN_SECTION_MAPPING = {
     'STDLIB': 'STANDARD_LIBRARY',
     'FUTURE': 'FUTURE_LIBRARY',
@@ -268,13 +268,6 @@ class RequirementsFinder(ReqsBaseFinder):
     def _get_files_from_dir(self, path: str) -> Iterator[str]:
         """Return paths to requirements files from passed dir.
         """
-        return RequirementsFinder._get_files_from_dir_cached(path)
-
-    @classmethod
-    @lru_cache(maxsize=16)
-    def _get_files_from_dir_cached(cls, path):
-        results = []
-
         for fname in os.listdir(path):
             if 'requirements' not in fname:
                 continue
@@ -283,38 +276,26 @@ class RequirementsFinder(ReqsBaseFinder):
             # *requirements*/*.{txt,in}
             if os.path.isdir(full_path):
                 for subfile_name in os.listdir(path):
-                    for ext in cls.exts:
+                    for ext in self.exts:
                         if subfile_name.endswith(ext):
-                            results.append(os.path.join(path, subfile_name))
+                            yield os.path.join(path, subfile_name)
                 continue
 
             # *requirements*.{txt,in}
             if os.path.isfile(full_path):
-                for ext in cls.exts:
+                for ext in self.exts:
                     if fname.endswith(ext):
-                        results.append(full_path)
+                        yield full_path
                         break
 
-        return results
-
     def _get_names(self, path: str) -> Iterator[str]:
         """Load required packages from path to requirements file
         """
-        for i in RequirementsFinder._get_names_cached(path):
-            yield i
-
-    @classmethod
-    @lru_cache(maxsize=16)
-    def _get_names_cached(cls, path: str) -> List[str]:
-        result = []
-
         with chdir(os.path.dirname(path)):
             requirements = parse_requirements(path, session=PipSession())
             for req in requirements:
                 if req.name:
-                    result.append(req.name)
-
-        return result
+                    yield req.name
 
 
 class PipfileFinder(ReqsBaseFinder):
diff --git a/isort/isort.py b/isort/isort.py
index ca28d56d..a763c92c 100644
--- a/isort/isort.py
+++ b/isort/isort.py
@@ -283,13 +283,10 @@ class SortImports(object):
         ignore_case: bool = False,
         section_name: Optional[Any] = None
     ) -> str:
-        dots = 0
-        while module_name.startswith('.'):
-            dots += 1
-            module_name = module_name[1:]
-
-        if dots:
-            module_name = '{} {}'.format(('.' * dots), module_name)
+        match = re.match(r'^(\.+)\s*(.*)', module_name)
+        if match:
+            sep = ' ' if config['reverse_relative'] else '_'
+            module_name = sep.join(match.groups())
 
         prefix = ""
         if ignore_case:
diff --git a/isort/main.py b/isort/main.py
index a2d4b935..80dbc712 100644
--- a/isort/main.py
+++ b/isort/main.py
@@ -253,6 +253,8 @@ def parse_args(argv: Optional[Sequence[str]] = None) -> Dict[str, Any]:
     parser.add_argument('-r', dest='ambiguous_r_flag', action='store_true')
     parser.add_argument('-rm', '--remove-import', dest='remove_imports', action='append',
                         help='Removes the specified import from all files.')
+    parser.add_argument('-rr', '--reverse-relative', dest='reverse_relative', action='store_true',
+                        help='Reverse order of relative imports.')
     parser.add_argument('-rc', '--recursive', dest='recursive', action='store_true',
                         help='Recursively look for Python files of which to sort imports')
     parser.add_argument('-s', '--skip', help='Files that sort imports should skip over. If you want to skip multiple '
diff --git a/isort/settings.py b/isort/settings.py
index ea8fe962..815ed0a7 100644
--- a/isort/settings.py
+++ b/isort/settings.py
@@ -28,6 +28,7 @@ import fnmatch
 import os
 import posixpath
 import re
+import stat
 import warnings
 from distutils.util import strtobool
 from functools import lru_cache
@@ -67,7 +68,7 @@ class WrapModes(enum.Enum):
 
     @staticmethod
     def from_string(value: str) -> 'WrapModes':
-        return getattr(WrapModes, value, None) or WrapModes(int(value))
+        return WrapModes(int(value))
 
 
 # Note that none of these lists must be complete as they are simply fallbacks for when included auto-detection fails.
@@ -134,6 +135,7 @@ default = {'force_to_top': [],
            'length_sort': False,
            'add_imports': [],
            'remove_imports': [],
+           'reverse_relative': False,
            'force_single_line': False,
            'default_section': 'FIRSTPARTY',
            'import_heading_future': '',
@@ -270,7 +272,7 @@ def _update_with_config_file(
                 result = default.get(access_key) if value.lower().strip() == 'false' else 2
             computed_settings[access_key] = result
         else:
-            computed_settings[access_key] = getattr(existing_value_type, str(value), None) or existing_value_type(value)
+            computed_settings[access_key] = existing_value_type(value)
 
 
 def _as_list(value: str) -> List[str]:
@@ -336,10 +338,7 @@ def should_skip(
     path: str = '/'
 ) -> bool:
     """Returns True if the file should be skipped based on the passed in settings."""
-    os_path = os.path.join(path, filename)
-    normalized_path = os_path.replace('\\', '/')
-    if normalized_path[1:2] == ':':
-        normalized_path = normalized_path[2:]
+    normalized_path = posixpath.join(path.replace('\\', '/'), filename)
 
     if config['safety_excludes'] and safety_exclude_re.search(normalized_path):
         return True
@@ -358,7 +357,7 @@ def should_skip(
         if fnmatch.fnmatch(filename, glob):
             return True
 
-    if not (os.path.isfile(os_path) or os.path.isdir(os_path) or os.path.islink(os_path)):
+    if stat.S_ISFIFO(os.stat(normalized_path).st_mode):
         return True
 
     return False
diff --git a/setup.py b/setup.py
index 6858ad4e..fbcb933e 100755
--- a/setup.py
+++ b/setup.py
@@ -6,7 +6,7 @@ with open('README.rst') as f:
     readme = f.read()
 
 setup(name='isort',
-      version='4.3.9',
+      version='4.3.8',
       description='A Python utility / library to sort Python imports.',
       long_description=readme,
       author='Timothy Crosley',
diff --git a/tox.ini b/tox.ini
index 2ea72f4c..488f9488 100644
--- a/tox.ini
+++ b/tox.ini
@@ -13,7 +13,7 @@ extras =
     pipfile
     pyproject
     requirements
-commands = py.test -vv -s test_isort.py {posargs}
+commands = py.test test_isort.py {posargs}
 
 [testenv:isort-check]
 basepython = python3


Test patch:
diff --git a/test_isort.py b/test_isort.py
index ad8cc30a..cbc0ba00 100644
--- a/test_isort.py
+++ b/test_isort.py
@@ -21,10 +21,8 @@ OTHER DEALINGS IN THE SOFTWARE.
 
 """
 from tempfile import NamedTemporaryFile
-import io
 import os
 import os.path
-import posixpath
 import sys
 import sysconfig
 
@@ -53,6 +51,7 @@ skip = build,.tox,venv
 balanced_wrapping = true
 not_skip = __init__.py
 """
+
 SHORT_IMPORT = "from third_party import lib1, lib2, lib3, lib4"
 SINGLE_FROM_IMPORT = "from third_party import lib1"
 SINGLE_LINE_LONG_IMPORT = "from third_party import lib1, lib2, lib3, lib4, lib5, lib5ab"
@@ -69,9 +68,8 @@ def default_settings_path(tmpdir_factory):
     config_file = config_dir.join('.editorconfig').strpath
     with open(config_file, 'w') as editorconfig:
         editorconfig.write(TEST_DEFAULT_CONFIG)
-
-    with config_dir.as_cwd():
-        yield config_dir.strpath
+        os.chdir(config_dir.strpath)
+    return config_dir.strpath
 
 
 def test_happy_path():
@@ -567,7 +565,7 @@ def test_skip_with_file_name():
     test_input = ("import django\n"
                   "import myproject\n")
 
-    sort_imports = SortImports(file_path='/baz.py', file_contents=test_input, settings_path=os.getcwd(),
+    sort_imports = SortImports(file_path='/baz.py', file_contents=test_input, known_third_party=['django'],
                                skip=['baz.py'])
     assert sort_imports.skipped
     assert sort_imports.output is None
@@ -1723,7 +1721,7 @@ def test_other_file_encodings(tmpdir):
         tmp_fname = tmpdir.join('test_{0}.py'.format(encoding))
         file_contents = "# coding: {0}\n\ns = u'ã'\n".format(encoding)
         tmp_fname.write_binary(file_contents.encode(encoding))
-        assert SortImports(file_path=str(tmp_fname), settings_path=os.getcwd()).output == file_contents
+        assert SortImports(file_path=str(tmp_fname)).output == file_contents
 
 
 def test_comment_at_top_of_file():
@@ -2245,8 +2243,7 @@ def test_inconsistent_behavior_in_python_2_and_3_issue_479():
     """Test to ensure Python 2 and 3 have the same behavior"""
     test_input = ('from future.standard_library import hooks\n'
                   'from workalendar.europe import UnitedKingdom\n')
-    assert SortImports(file_contents=test_input,
-                       known_first_party=["future"]).output == test_input
+    assert SortImports(file_contents=test_input).output == test_input
 
 
 def test_sort_within_section_comments_issue_436():
@@ -2519,49 +2516,32 @@ def test_to_ensure_tabs_dont_become_space_issue_665():
 
 
 def test_new_lines_are_preserved():
-    with NamedTemporaryFile('w', suffix='py', delete=False) as rn_newline:
-        pass
-
-    try:
-        with io.open(rn_newline.name, mode='w', newline='') as rn_newline_input:
+    with NamedTemporaryFile('w', suffix='py') as rn_newline:
+        with open(rn_newline.name, 'w', newline='') as rn_newline_input:
             rn_newline_input.write('import sys\r\nimport os\r\n')
-
-        SortImports(rn_newline.name, settings_path=os.getcwd())
-        with io.open(rn_newline.name) as new_line_file:
-            print(new_line_file.read())
-        with io.open(rn_newline.name, newline='') as rn_newline_file:
-            rn_newline_contents = rn_newline_file.read()
-        assert rn_newline_contents == 'import os\r\nimport sys\r\n'
-    finally:
-        os.remove(rn_newline.name)
-
-    with NamedTemporaryFile('w', suffix='py', delete=False) as r_newline:
-        pass
-
-    try:
-        with io.open(r_newline.name, mode='w', newline='') as r_newline_input:
+            rn_newline_input.flush()
+            SortImports(rn_newline.name)
+            with open(rn_newline.name, newline='') as rn_newline_file:
+                rn_newline_contents = rn_newline_file.read()
+    assert rn_newline_contents == 'import os\r\nimport sys\r\n'
+
+    with NamedTemporaryFile('w', suffix='py') as r_newline:
+        with open(r_newline.name, 'w', newline='') as r_newline_input:
             r_newline_input.write('import sys\rimport os\r')
-
-        SortImports(r_newline.name, settings_path=os.getcwd())
-        with io.open(r_newline.name, newline='') as r_newline_file:
-            r_newline_contents = r_newline_file.read()
-        assert r_newline_contents == 'import os\rimport sys\r'
-    finally:
-        os.remove(r_newline.name)
-
-    with NamedTemporaryFile('w', suffix='py', delete=False) as n_newline:
-        pass
-
-    try:
-        with io.open(n_newline.name, mode='w', newline='') as n_newline_input:
+            r_newline_input.flush()
+            SortImports(r_newline.name)
+            with open(r_newline.name, newline='') as r_newline_file:
+                r_newline_contents = r_newline_file.read()
+    assert r_newline_contents == 'import os\rimport sys\r'
+
+    with NamedTemporaryFile('w', suffix='py') as n_newline:
+        with open(n_newline.name, 'w', newline='') as n_newline_input:
             n_newline_input.write('import sys\nimport os\n')
-
-        SortImports(n_newline.name, settings_path=os.getcwd())
-        with io.open(n_newline.name, newline='') as n_newline_file:
-            n_newline_contents = n_newline_file.read()
-        assert n_newline_contents == 'import os\nimport sys\n'
-    finally:
-        os.remove(n_newline.name)
+            n_newline_input.flush()
+            SortImports(n_newline.name)
+            with open(n_newline.name, newline='') as n_newline_file:
+                n_newline_contents = n_newline_file.read()
+    assert n_newline_contents == 'import os\nimport sys\n'
 
 
 @pytest.mark.skipif(not finders.RequirementsFinder.enabled, reason='RequirementsFinder not enabled (too old version of pip?)')
@@ -2685,11 +2665,11 @@ def test_path_finder(monkeypatch):
     third_party_prefix = next(path for path in finder.paths if "site-packages" in path)
     ext_suffix = sysconfig.get_config_var("EXT_SUFFIX") or ".so"
     imaginary_paths = set([
-        posixpath.join(finder.stdlib_lib_prefix, "example_1.py"),
-        posixpath.join(third_party_prefix, "example_2.py"),
-        posixpath.join(third_party_prefix, "example_3.so"),
-        posixpath.join(third_party_prefix, "example_4" + ext_suffix),
-        posixpath.join(os.getcwd(), "example_5.py"),
+        os.path.join(finder.stdlib_lib_prefix, "example_1.py"),
+        os.path.join(third_party_prefix, "example_2.py"),
+        os.path.join(third_party_prefix, "example_3.so"),
+        os.path.join(third_party_prefix, "example_4" + ext_suffix),
+        os.path.join(os.getcwd(), "example_5.py"),
     ])
     monkeypatch.setattr("isort.finders.exists_case_sensitive", lambda p: p in imaginary_paths)
     assert finder.find("example_1") == finder.sections.STDLIB
@@ -2713,18 +2693,17 @@ def test_command_line(tmpdir, capfd, multiprocess):
     from isort.main import main
     tmpdir.join("file1.py").write("import re\nimport os\n\nimport contextlib\n\n\nimport isort")
     tmpdir.join("file2.py").write("import collections\nimport time\n\nimport abc\n\n\nimport isort")
-    arguments = ["-rc", str(tmpdir), '--settings-path', os.getcwd()]
+    arguments = ["-rc", str(tmpdir)]
     if multiprocess:
         arguments.extend(['--jobs', '2'])
     main(arguments)
     assert tmpdir.join("file1.py").read() == "import contextlib\nimport os\nimport re\n\nimport isort\n"
     assert tmpdir.join("file2.py").read() == "import abc\nimport collections\nimport time\n\nimport isort\n"
-    if not sys.platform.startswith('win'):
-        out, err = capfd.readouterr()
-        assert not err
-        # it informs us about fixing the files:
-        assert str(tmpdir.join("file1.py")) in out
-        assert str(tmpdir.join("file2.py")) in out
+    out, err = capfd.readouterr()
+    assert not err
+    # it informs us about fixing the files:
+    assert str(tmpdir.join("file1.py")) in out
+    assert str(tmpdir.join("file2.py")) in out
 
 
 @pytest.mark.parametrize('enabled', (False, True))
@@ -2734,15 +2713,12 @@ def test_safety_excludes(tmpdir, enabled):
     tmpdir.mkdir("lib").mkdir("python3.7").join("importantsystemlibrary.py").write("# ...")
     config = dict(settings.default.copy(), safety_excludes=enabled)
     skipped = []
-    codes = [str(tmpdir)],
-    main.iter_source_code(codes, config, skipped)
     file_names = set(os.path.relpath(f, str(tmpdir)) for f in main.iter_source_code([str(tmpdir)], config, skipped))
     if enabled:
         assert file_names == {'victim.py'}
         assert len(skipped) == 2
     else:
-        assert file_names == {os.sep.join(('.tox', 'verysafe.py')),
-                              os.sep.join(('lib', 'python3.7', 'importantsystemlibrary.py')), 'victim.py'}
+        assert file_names == {'.tox/verysafe.py', 'lib/python3.7/importantsystemlibrary.py', 'victim.py'}
         assert not skipped
 
 
@@ -2753,7 +2729,7 @@ def test_comments_not_removed_issue_576():
     assert SortImports(file_contents=test_input).output == test_input
 
 
-def test_inconsistent_relative_imports_issue_577():
+def test_reverse_relative_imports_issue_417():
     test_input = ('from . import ipsum\n'
                   'from . import lorem\n'
                   'from .dolor import consecteur\n'
@@ -2766,6 +2742,24 @@ def test_inconsistent_relative_imports_issue_577():
                   'from ... import dui\n'
                   'from ...eu import dignissim\n'
                   'from ...ex import metus\n')
+    assert SortImports(file_contents=test_input,
+                       force_single_line=True,
+                       reverse_relative=True).output == test_input
+
+
+def test_inconsistent_relative_imports_issue_577():
+    test_input = ('from ... import diam\n'
+                  'from ... import dui\n'
+                  'from ...eu import dignissim\n'
+                  'from ...ex import metus\n'
+                  'from .. import donec\n'
+                  'from .. import euismod\n'
+                  'from ..mi import iaculis\n'
+                  'from ..nec import tempor\n'
+                  'from . import ipsum\n'
+                  'from . import lorem\n'
+                  'from .dolor import consecteur\n'
+                  'from .sit import apidiscing\n')
     assert SortImports(file_contents=test_input, force_single_line=True).output == test_input