Repository: facebookexperimental/usort (python)

Issue:
Support excluding files from a configuration file
As of now `usort` does not provide an option to exclude certain files from a configuration file. There is the `# usort:skip` (or `# isort:skip`) directive, but this gets tedious fast if one wants to skip a complete directory or files based on a pattern.

There are several options from other formatters / linters how to achieve this. Since `usort` should be used with a code formatter and `black` is an obvious choice, IMO it would be a good idea to adhere to their `--exclude` behavior, i.e. regular expression matching.

Pull request:
Add new `excludes` configuration option
Supports reading a list of gitignore patterns from
`tool.usort.excludes`, and passes those values to trailrunner when
walking paths in `usort_path()`.

Includes tests and documentation updates to match.

Fixes #62 

Docs preview: https://usort--111.org.readthedocs.build/en/111/guide.html#excludes

Patch:
diff --git a/docs/conf.py b/docs/conf.py
index 47d93ef..a14e842 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -57,6 +57,7 @@ autodoc_member_order = "groupwise"
 highlight_language = "python3"
 intersphinx_mapping = {
     "python": ("https://docs.python.org/3", None),
+    "pathspec": ("https://python-path-specification.readthedocs.io/en/latest/", None),
     "stdlib_list": ("https://python-stdlib-list.readthedocs.io/en/latest/", None),
 }
 master_doc = "index"
diff --git a/docs/guide.rst b/docs/guide.rst
index 667f0dd..2853d5c 100644
--- a/docs/guide.rst
+++ b/docs/guide.rst
@@ -461,6 +461,32 @@ The following options are valid for the main ``tool.usort`` table:
     Whether to merge sequential imports from the same base module.
     See `Merging`_ for details on how this works.
 
+.. attribute:: excludes
+    :type: List[str]
+
+    List of "gitignore" style filename patterns to exclude when sorting paths.
+    This will supplement any ignored paths from the project root's ``.gitignore`` file,
+    and any file or directory that matches these patterns will not be sorted.
+
+    Example:
+
+    .. code-block:: toml
+
+        [tool.usort]
+        excludes = [
+            "test/fixtures/",
+            "*_generated.py",
+        ]
+
+    This configuration would match and exclude the following files:
+
+    * ``test/fixtures/something_good.py``
+    * ``foo/test/fixtures/something_bad.py``
+    * ``foo/client/robot_generated.py``
+
+    See the :std:doc:`pathspec <pathspec:index>` and
+    :py:class:`GitWildPatchPattern <pathspec.patterns.gitwildmatch.GitWildMatchPattern>`
+    documentation for details of what patterns are allowed and how they are applied.
 
 ``[tool.usort.known]``
 %%%%%%%%%%%%%%%%%%%%%%
diff --git a/usort/api.py b/usort/api.py
index a30cd82..19dc0dd 100644
--- a/usort/api.py
+++ b/usort/api.py
@@ -95,6 +95,8 @@ def usort_string(data: str, config: Config, path: Optional[Path] = None) -> str:
 def usort_file(path: Path, *, write: bool = False) -> Result:
     """
     Format a single file and return a Result object.
+
+    Ignores any configured :py:attr:`excludes` patterns.
     """
 
     try:
@@ -118,10 +120,15 @@ def usort_file(path: Path, *, write: bool = False) -> Result:
 def usort_path(path: Path, *, write: bool = False) -> Iterable[Result]:
     """
     For a given path, format it, or any python files in it, and yield :class:`Result` s.
+
+    If given a directory, it will be searched, recursively, for any Python source files,
+    excluding any files or directories that match the project root's ``.gitignore`` or
+    any configured :py:attr:`excludes` patterns in the associated ``pyproject.toml``.
     """
     with timed(f"total for {path}"):
         with timed(f"walking {path}"):
-            paths = list(walk(path))
+            config = Config.find(path)
+            paths = list(walk(path, excludes=config.excludes))
 
         fn = partial(usort_file, write=write)
         results = [v for v in run(paths, fn).values()]
diff --git a/usort/config.py b/usort/config.py
index d441299..eb75489 100644
--- a/usort/config.py
+++ b/usort/config.py
@@ -58,6 +58,9 @@ class Config:
     # Whether to merge imports when sorting
     merge_imports: bool = True
 
+    # gitignore-style filename patterns to exclude when sorting entire directories
+    excludes: List[str] = field(default_factory=list)
+
     # Needed for formatting final imports
     line_length: int = 88
 
@@ -155,6 +158,8 @@ class Config:
             self.first_party_detection = tbl["first_party_detection"]
         if "merge_imports" in tbl:
             self.merge_imports = tbl["merge_imports"]
+        if "excludes" in tbl:
+            self.excludes = tbl["excludes"]
 
         for cat, names in tbl.get("known", {}).items():
             typed_cat = Category(cat)


Test patch:
diff --git a/usort/tests/config.py b/usort/tests/config.py
index bd962a1..3e00347 100644
--- a/usort/tests/config.py
+++ b/usort/tests/config.py
@@ -171,6 +171,31 @@ foo = ["numpy", "pandas"]
             # from foo.bar import bazzy
             self.assertFalse(config.is_side_effect_import("foo.bar", ["bazzy"]))
 
+    def test_config_excludes(self) -> None:
+        with tempfile.TemporaryDirectory() as d:
+            d_path = Path(d)
+            (d_path / "foo").mkdir(parents=True)
+            (d_path / "foo" / "bar.py").write_text("import os")
+
+            with self.subTest("default config"):
+                (d_path / "foo" / "pyproject.toml").write_text("")
+                conf = Config.find(d_path / "foo" / "bar.py")
+                self.assertEqual([], conf.excludes)
+
+            with self.subTest("with black config"):
+                (d_path / "foo" / "pyproject.toml").write_text(
+                    """\
+[tool.usort]
+excludes = [
+    "fixtures/",
+    "*generated.py",
+]
+"""
+                )
+                conf = Config.find(d_path / "foo" / "bar.py")
+                expected = ["fixtures/", "*generated.py"]
+                self.assertEqual(expected, conf.excludes)
+
     def test_black_line_length(self) -> None:
         with tempfile.TemporaryDirectory() as d:
             d_path = Path(d)
diff --git a/usort/tests/functional.py b/usort/tests/functional.py
index 3a94188..89f62ef 100644
--- a/usort/tests/functional.py
+++ b/usort/tests/functional.py
@@ -10,7 +10,7 @@ from pathlib import Path
 from textwrap import dedent
 from typing import Optional
 
-from ..api import usort
+from ..api import usort, usort_path
 from ..config import Config
 from ..translate import import_from_node
 from ..util import parse_import
@@ -859,6 +859,43 @@ numpy = ["numpy", "pandas"]
             """,
         )
 
+    def test_excludes(self) -> None:
+        original_content = "import os\nimport asyncio\n"
+        sorted_content = b"import asyncio\nimport os\n"
+        with tempfile.TemporaryDirectory() as td:
+            tdp = Path(td).resolve()
+            (tdp / "pyproject.toml").write_text(
+                """\
+[tool.usort]
+excludes = [
+    "fixtures/",
+    "*generated.py",
+]
+"""
+            )
+            (tdp / "foo" / "tests" / "fixtures").mkdir(parents=True)
+
+            excluded_paths = (
+                (tdp / "foo" / "tests" / "fixtures" / "foo.py"),
+                (tdp / "foo" / "tests" / "foo_generated.py"),
+                (tdp / "foo" / "client_generated.py"),
+            )
+            sorted_paths = (
+                (tdp / "foo" / "tests" / "foo.py"),
+                (tdp / "foo" / "foo.py"),
+                (tdp / "foo" / "generated_client.py"),
+            )
+
+            for path in excluded_paths + sorted_paths:
+                path.write_text(original_content)
+
+            results = list(usort_path(tdp / "foo"))
+            for result in results:
+                self.assertIn(result.path, sorted_paths)
+                self.assertNotIn(result.path, excluded_paths)
+                self.assertEqual(sorted_content, result.output.replace(b"\r\n", b"\n"))
+            self.assertEqual(len(sorted_paths), len(results))
+
 
 if __name__ == "__main__":
     unittest.main()