Repository: facebookexperimental/usort (python)

Issue:
Feature: split basic imports
```py
import os, sys, time
```
should be split into multiple import statements:
```py
import os
import sys
import time
```

Pull request description:
Split basic import statements with multiple imported names
Before sorting and merging imports within a block, this looks for basic
(non-from) imports with more than one imported name, and then splits
that import into a separate import for each name. Comments are
duplicated from the original import to all split imports.

Fixes #140

Docs preview: https://usort--141.org.readthedocs.build/en/141/

Patch:
diff --git a/docs/guide.rst b/docs/guide.rst
index 065f9d5..350188e 100644
--- a/docs/guide.rst
+++ b/docs/guide.rst
@@ -34,11 +34,12 @@ Sorting
 
 1. Look for all import statements in the module
 2. Group these statements into "blocks" of sortable imports
-   (See `Import Blocks`_ for details)
-3. Reorder import statements within each block
-4. Merge sequential import statements from the same module (See `Merging`_ for details)
-5. Reorder imported names within each statement
-6. Normalize whitespace between imports as needed
+   (See `Import Blocks`_)
+3. Split basic import statements within each block (See `Splitting`_)
+4. Reorder import statements within each block
+5. Merge sequential import statements from the same module (See `Merging`_)
+6. Reorder imported names within each statement
+7. Normalize whitespace between imports as needed
 
 When ordering imports within a block, µsort categorizes the imports by source
 into four major categories for imports, prioritized following common community
@@ -81,6 +82,40 @@ to this example, for a module in the namespace :mod:`something`::
     from .other_module import SomeClass, some_thing, TestFixture
 
 
+Splitting
+---------
+
+µsort will split basic imports into separate statements. This allows µsort to
+correctly categorize and sort basic imports with stable and consistent locations.
+
+For example, given the following imports::
+
+    import os, sys, traceback, foo, bar
+
+After running µsort, these imports would be split apart::
+
+    import os
+    import sys
+    import traceback
+
+    import bar
+    import foo
+
+For imports with associated comments (See `Associations`_), µsort will duplicate those
+comments to all imports split out from the original statement, in order to preserve
+any semantic comment directives::
+
+    # something important
+    import foo, bar  # noqa
+
+After splitting, this becomes::
+
+    # something important
+    import bar  # noqa
+    # something important
+    import foo  # noqa
+
+
 Merging
 -------
 
@@ -237,7 +272,10 @@ based on simple heuristics for ownership:
     that precedes the comment.
 
 Given the number of possible places for comments in the Python grammar for a single
-import statement, it may be easier to follow this example::
+import statement, it may be easier to follow these examples::
+
+    # IMPORT
+    import alfa, bravo, charlie  # IMPORT
 
     # IMPORT
     from foo import (  # IMPORT
diff --git a/usort/sorting.py b/usort/sorting.py
index 35db215..07c0711 100644
--- a/usort/sorting.py
+++ b/usort/sorting.py
@@ -8,6 +8,7 @@ from pathlib import Path
 from typing import Dict, List, Optional, Sequence, Set, Tuple
 
 import libcst as cst
+from attr import evolve
 from libcst.metadata import PositionProvider
 
 from .config import Config
@@ -265,6 +266,26 @@ class ImportSorter:
 
         return imports
 
+    def split_imports(self, imports: List[SortableImport]) -> List[SortableImport]:
+        idx = 0
+        while idx < len(imports):
+            imp = imports[idx]
+            if imp.stem is None and len(imp.items) > 1:  # import foo, bar
+                new_imps = [
+                    SortableImport(
+                        stem=None,
+                        items=[item],
+                        comments=evolve(imp.comments),
+                        indent=imp.indent,
+                        config=imp.config,
+                        node=imp.node,
+                    )
+                    for item in imp.items
+                ]
+                imports[idx : idx + 1] = new_imps
+            idx += 1
+        return imports
+
     def find_and_sort_blocks(
         self,
         body: Sequence[cst.BaseStatement],
@@ -285,7 +306,8 @@ class ImportSorter:
             # Sort the imports first, so that imports from the same module line up, then
             # merge and sort imports/items, then re-sort the final set of imports again
             # in case unsorted items affected overall sorting.
-            imports = sorted(block.imports)
+            imports = self.split_imports(block.imports)
+            imports = sorted(imports)
             imports = self.merge_and_sort_imports(imports)
             imports = self.fixup_whitespace(initial_blank, imports)
             block.imports = sorted(imports)


Test patch:
diff --git a/usort/tests/functional.py b/usort/tests/functional.py
index 1c2e4b7..8d7b384 100644
--- a/usort/tests/functional.py
+++ b/usort/tests/functional.py
@@ -576,19 +576,6 @@ numpy = ["numpy", "pandas"]
             """,
         )
 
-    def test_single_line_import_long_comment(self) -> None:
-        """
-        Basic import statements can't be reflowed to multiple lines
-        """
-        self.assertUsortResult(
-            """
-                import foo, bar, baz  # some really long inline comment that would can't be reflowed to a new line
-            """,
-            """
-                import bar, baz, foo  # some really long inline comment that would can't be reflowed to a new line
-            """,
-        )
-
     def test_sorting_import_items(self) -> None:
         self.assertUsortResult(
             """
@@ -598,7 +585,9 @@ numpy = ["numpy", "pandas"]
             """
                 from typing import Dict, List, Optional, Pattern, Set
 
-                import a, b, c
+                import a
+                import b
+                import c
             """,
         )
 
@@ -637,12 +626,75 @@ numpy = ["numpy", "pandas"]
             """,
             """
                 import difflib
+                import os
 
-                import attr, os
+                import attr
                 import third_party
             """,
         )
 
+    def test_splitting_imports_simple(self) -> None:
+        self.assertUsortResult(
+            """
+                import sys, os, fancy, time, re
+                import math, usort, bisect
+            """,
+            """
+                import bisect
+                import math
+                import os
+                import re
+                import sys
+                import time
+
+                import fancy
+                import usort
+            """,
+        )
+
+    def test_splitting_imports_comments(self) -> None:
+        self.assertUsortResult(
+            """
+                import difflib
+                # directive: foo
+                import sys, os, fancy, time, re
+                import math, usort, bisect  # noqa
+                # after
+            """,
+            """
+                import bisect  # noqa
+                import difflib
+                import math  # noqa
+                # directive: foo
+                import os
+                # directive: foo
+                import re
+                # directive: foo
+                import sys
+                # directive: foo
+                import time
+
+                # directive: foo
+                import fancy
+                import usort  # noqa
+                # after
+            """,
+        )
+
+    def test_splitting_and_merging_with_comments(self) -> None:
+        self.assertUsortResult(
+            """
+                import math, usort  # noqa
+                import black, math  # fun
+            """,
+            """
+                import math  # noqa  # fun
+
+                import black  # fun
+                import usort  # noqa
+            """,
+        )
+
     def test_merging_import_items(self) -> None:
         self.assertUsortResult(
             """