Repository: adamchainz/flake8-tidy-imports

Issue:
I252 does not allow for fine-tuning
### Python Version

3.9.0

### Package Version

latest

### Description

It's more of a feature request than an issue :

Currently I252 can only be a boolean
```
ban-relative-imports = True
```

As stated by the [pep documentation](https://www.python.org/dev/peps/pep-0008/#imports) using relative import, is acceptable when dealing with complex package layouts.
```python3
from .sibling import example
# is more readable than
from my.project.sub.module.sibling import example
```

But abusing the relative import by searching into a parent module makes it less readable
```python3
from ....parent import example
# is less readable than
from my.project.parent import example
```

So the idea would be to have an option to specify the maximum level of *relativeness* that is allowed in the project
Something like
```
max-relative-import = 1
```
```python3
# This would pass
from .sibling import example

# This would fail
from ..parent import example 
```
(I'm not good at naming things so any idea is welcome !)

I can make a PR if needed :)

Discussion:

Patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index 53142e6..a2244a2 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -2,6 +2,9 @@
 History
 =======
 
+* Add ``parents`` option for ``ban-relative-imports`` that allows using
+  relative syntax to import from sibling modules, but not parents.
+
 4.3.0 (2021-05-10)
 ------------------
 
diff --git a/README.rst b/README.rst
index 71dcd8c..6e2612d 100644
--- a/README.rst
+++ b/README.rst
@@ -85,14 +85,19 @@ For example:
 ``ban-relative-imports``
 ------------------------
 
-When set to 'true', enables rule I252 (below), which bans relative imports:
+Controls rule I252 (below). Accepts two values:
+
+* ``parents`` - bans imports from parent modules (and grandparents, etc.), i.e. with more than one ``.``.
+* ``true`` - bans all relative imports.
+
+For example:
 
 .. code-block:: ini
 
     [flake8]
-    ban-relative-imports = true
+    ban-relative-imports = parents
 
-(Or, if you want to ban absolute imports, you can put your project's modules in ``banned-modules``.)
+(If you want to ban absolute imports, you can put your project's modules in ``banned-modules``.)
 
 Rules
 =====
@@ -132,20 +137,34 @@ For example:
     $ flake8 file.py
     file.py:1:1: I251 Banned import 'mock' used - use unittest.mock instead.
 
-I252: Relative imports are banned.
-----------------------------------
+I252: Relative imports <from parent modules> are banned.
+--------------------------------------------------------
 
 Complains about use of relative imports:
 
-* ``from . import foo``
-* ``from .bar import foo``
+* ``from . import foo`` (sibling import)
+* ``from .bar import foo`` (sibling import)
+* ``from .. import foo`` (parent import)
 
-Needs enabling with ``ban-relative-imports`` configuration option.
+Controlled by the ``ban-relative-imports`` configuration option.
 
-Absolute imports are recommended by `PEP8 <https://www.python.org/dev/peps/pep-0008/>`__:
+Absolute imports, or relative imports from siblings, are recommended by `PEP8 <https://www.python.org/dev/peps/pep-0008/>`__:
 
     Absolute imports are recommended, as they are usually more readable and tend to be better behaved...
 
+    .. code-block:: python
+
+        import mypkg.sibling
+        from mypkg import sibling
+        from mypkg.sibling import example
+
+    However, explicit relative imports are an acceptable alternative to absolute imports...
+
+    .. code-block:: python
+
+        from . import sibling
+        from .sibling import example
+
 See also
 --------
 
diff --git a/src/flake8_tidy_imports.py b/src/flake8_tidy_imports.py
index a0d9be6..3d47f79 100644
--- a/src/flake8_tidy_imports.py
+++ b/src/flake8_tidy_imports.py
@@ -39,10 +39,13 @@ class ImportChecker:
 
         parser.add_option(
             "--ban-relative-imports",
-            action="store_true",
-            default=False,
+            action="store",
+            nargs="?",
+            const="true",
             parse_from_config=True,
-            help="Ban relative imports (use absolute imports instead).",
+            choices=["", "parents", "true"],
+            default="",
+            help="Ban relative imports, from parental modules or in all cases.",
         )
 
     @classmethod
@@ -66,7 +69,6 @@ class ImportChecker:
 
     message_I250 = "I250 Unnecessary import alias - rewrite as '{}'."
     message_I251 = "I251 Banned import '{name}' used - {msg}."
-    message_I252 = "I252 Relative imports are banned."
 
     def run(self) -> Generator[Tuple[int, int, str, Type[Any]], None, None]:
         rule_funcs = (self.rule_I250, self.rule_I251, self.rule_I252)
@@ -147,12 +149,21 @@ class ImportChecker:
     def rule_I252(
         self, node: ast.AST
     ) -> Generator[Tuple[int, int, str, Type[Any]], None, None]:
+        if not self.ban_relative_imports:
+            return
+        elif self.ban_relative_imports == "parents":
+            min_node_level = 1
+            message = "I252 Relative imports from parent modules are banned."
+        else:
+            min_node_level = 0
+            message = "I252 Relative imports are banned."
+
         if (
             self.ban_relative_imports
             and isinstance(node, ast.ImportFrom)
-            and node.level != 0
+            and node.level > min_node_level
         ):
-            yield (node.lineno, node.col_offset, self.message_I252, type(self))
+            yield (node.lineno, node.col_offset, message, type(self))
 
     python2to3_banned_modules = {
         "__builtin__": "use six.moves.builtins as a drop-in replacement",


Test patch:
diff --git a/tests/test_flake8_tidy_imports.py b/tests/test_flake8_tidy_imports.py
index 0e746f9..3bbcfa0 100644
--- a/tests/test_flake8_tidy_imports.py
+++ b/tests/test_flake8_tidy_imports.py
@@ -484,3 +484,91 @@ def test_I252_relative_import_commandline(flake8_path):
     )
     result = flake8_path.run_flake8(["--ban-relative-imports"])
     assert result.out_lines == ["./example.py:1:1: I252 Relative imports are banned."]
+
+
+def test_I252_relative_import_parents1(flake8_path):
+    (flake8_path / "example.py").write_text(
+        dedent(
+            """\
+            from . import foo
+
+            foo
+            """
+        )
+    )
+    (flake8_path / "setup.cfg").write_text(
+        default_setup_cfg + "ban-relative-imports = parents"
+    )
+    result = flake8_path.run_flake8()
+    assert result.out_lines == []
+
+
+def test_I252_relative_import_parents2(flake8_path):
+    (flake8_path / "example.py").write_text(
+        dedent(
+            """\
+            from .foo import bar
+
+            bar
+            """
+        )
+    )
+    (flake8_path / "setup.cfg").write_text(
+        default_setup_cfg + "ban-relative-imports = parents"
+    )
+    result = flake8_path.run_flake8()
+    assert result.out_lines == []
+
+
+def test_I252_relative_import_parents3(flake8_path):
+    (flake8_path / "example.py").write_text(
+        dedent(
+            """\
+            from .. import foo
+
+            foo
+            """
+        )
+    )
+    (flake8_path / "setup.cfg").write_text(
+        default_setup_cfg + "ban-relative-imports = parents"
+    )
+    result = flake8_path.run_flake8()
+    assert result.out_lines == [
+        "./example.py:1:1: I252 Relative imports from parent modules are banned."
+    ]
+
+
+def test_I252_relative_import_parents4(flake8_path):
+    (flake8_path / "example.py").write_text(
+        dedent(
+            """\
+            from ...foo import bar
+
+            bar
+            """
+        )
+    )
+    (flake8_path / "setup.cfg").write_text(
+        default_setup_cfg + "ban-relative-imports = parents"
+    )
+    result = flake8_path.run_flake8()
+    assert result.out_lines == [
+        "./example.py:1:1: I252 Relative imports from parent modules are banned."
+    ]
+
+
+def test_I252_relative_import_parents_commandline(flake8_path):
+    (flake8_path / "example.py").write_text(
+        dedent(
+            """\
+            from ... import bar
+
+            bar
+            """
+        )
+    )
+    result = flake8_path.run_flake8(["--ban-relative-imports=parents"])
+    assert result.out_lines == [
+        "./example.py:1:1: I252 Relative imports from parent modules are banned."
+    ]