Repository: asottile/pyupgrade

Issue:
Remove parentheses from @functools.lru_cache() when upgrading to Python 3.8
https://docs.python.org/library/functools.html#functools.lru_cache

In Python 3.7 you need to write `@lru_cache()`, in 3.8 you can just do `@lru_cache` without the parentheses. See https://bugs.python.org/issue36772

pyupgrade could remove the unnecessary parens for this and other decorators in the stdlib. 

If someone monkey-patches `lru_cache` in their code with an implementation that doesn't handle not having parens this would break their code.

Discussion:

Patch:
diff --git a/README.md b/README.md
index 6b84e7a..2cd525c 100644
--- a/README.md
+++ b/README.md
@@ -438,3 +438,17 @@ Availability:
 _note_: `pyupgrade` is intentionally timid and will not create an f-string
 if it would make the expression longer or if the substitution parameters are
 anything but simple names or dotted names (as this can decrease readability).
+
+### remove parentheses from `@functools.lru_cache()`
+
+Availability:
+- `--py38-plus` is passed on the commandline.
+
+```diff
+ import functools
+
+-@functools.lru_cache()
++@functools.lru_cache
+ def expensive():
+     ...
+```
diff --git a/pyupgrade.py b/pyupgrade.py
index 3834905..40e80de 100644
--- a/pyupgrade.py
+++ b/pyupgrade.py
@@ -1187,7 +1187,7 @@ class FindPy3Plus(ast.NodeVisitor):
         'socket',
     ))
 
-    FROM_IMPORTED_MODULES = OS_ERROR_ALIAS_MODULES.union(('six',))
+    FROM_IMPORTED_MODULES = OS_ERROR_ALIAS_MODULES.union(('functools', 'six'))
 
     class ClassInfo:
         def __init__(self, name: str) -> None:
@@ -1233,6 +1233,8 @@ class FindPy3Plus(ast.NodeVisitor):
         self._in_async_def = False
         self.yield_from_fors: Set[Offset] = set()
 
+        self.no_arg_decorators: Set[Offset] = set()
+
     def _is_six(self, node: ast.expr, names: Container[str]) -> bool:
         return (
             isinstance(node, ast.Name) and
@@ -1245,6 +1247,18 @@ class FindPy3Plus(ast.NodeVisitor):
             node.attr in names
         )
 
+    def _is_lru_cache(self, node: ast.expr) -> bool:
+        return (
+            isinstance(node, ast.Name) and
+            node.id == 'lru_cache' and
+            node.id in self._from_imports['functools']
+        ) or (
+            isinstance(node, ast.Attribute) and
+            isinstance(node.value, ast.Name) and
+            node.value.id == 'functools' and
+            node.attr == 'lru_cache'
+        )
+
     def _is_io_open(self, node: ast.expr) -> bool:
         return (
             isinstance(node, ast.Attribute) and
@@ -1484,6 +1498,12 @@ class FindPy3Plus(ast.NodeVisitor):
                 node.args[1].s in U_MODE_ALL
         ):
             self.open_mode_calls.add(_ast_to_offset(node))
+        elif (
+                not node.args and
+                not node.keywords and
+                self._is_lru_cache(node.func)
+        ):
+            self.no_arg_decorators.add(_ast_to_offset(node))
 
         self.generic_visit(node)
 
@@ -1879,7 +1899,7 @@ def _replace_yield(tokens: List[Token], i: int) -> None:
     tokens[i:block.end] = [Token('CODE', f'yield from {container}\n')]
 
 
-def _fix_py3_plus(contents_text: str) -> str:
+def _fix_py3_plus(contents_text: str, min_version: MinVersion) -> str:
     try:
         ast_obj = ast_parse(contents_text)
     except SyntaxError:
@@ -1901,6 +1921,7 @@ def _fix_py3_plus(contents_text: str) -> str:
             visitor.os_error_alias_calls,
             visitor.os_error_alias_simple,
             visitor.os_error_alias_excepts,
+            visitor.no_arg_decorators,
             visitor.six_add_metaclass,
             visitor.six_b,
             visitor.six_calls,
@@ -2157,6 +2178,13 @@ def _fix_py3_plus(contents_text: str) -> str:
             visitor.os_error_alias_excepts.discard(token.offset)
         elif token.offset in visitor.yield_from_fors:
             _replace_yield(tokens, i)
+        elif (
+                min_version >= (3, 8) and
+                token.offset in visitor.no_arg_decorators
+        ):
+            i = _find_open_paren(tokens, i)
+            j = _find_token(tokens, i, ')')
+            del tokens[i:j + 1]
 
     return tokens_to_src(tokens)
 
@@ -2477,7 +2505,7 @@ def _fix_file(filename: str, args: argparse.Namespace) -> int:
     if not args.keep_percent_format:
         contents_text = _fix_percent_format(contents_text)
     if args.min_version >= (3,):
-        contents_text = _fix_py3_plus(contents_text)
+        contents_text = _fix_py3_plus(contents_text, args.min_version)
     if args.min_version >= (3, 6):
         contents_text = _fix_py36_plus(contents_text)
 
@@ -2511,6 +2539,10 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
         '--py37-plus',
         action='store_const', dest='min_version', const=(3, 7),
     )
+    parser.add_argument(
+        '--py38-plus',
+        action='store_const', dest='min_version', const=(3, 8),
+    )
     args = parser.parse_args(argv)
 
     ret = 0


Test patch:
diff --git a/tests/default_encoding_test.py b/tests/default_encoding_test.py
index 856e433..4385583 100644
--- a/tests/default_encoding_test.py
+++ b/tests/default_encoding_test.py
@@ -16,7 +16,7 @@ from pyupgrade import _fix_py3_plus
     ),
 )
 def test_fix_encode(s, expected):
-    assert _fix_py3_plus(s) == expected
+    assert _fix_py3_plus(s, (3,)) == expected
 
 
 @pytest.mark.parametrize(
@@ -35,4 +35,4 @@ def test_fix_encode(s, expected):
     ),
 )
 def test_fix_encode_noop(s):
-    assert _fix_py3_plus(s) == s
+    assert _fix_py3_plus(s, (3,)) == s
diff --git a/tests/io_open_test.py b/tests/io_open_test.py
index f454abc..6a1286c 100644
--- a/tests/io_open_test.py
+++ b/tests/io_open_test.py
@@ -13,7 +13,7 @@ from pyupgrade import _fix_py3_plus
     ),
 )
 def test_fix_io_open_noop(s):
-    assert _fix_py3_plus(s) == s
+    assert _fix_py3_plus(s, (3,)) == s
 
 
 @pytest.mark.parametrize(
@@ -31,4 +31,4 @@ def test_fix_io_open_noop(s):
     ),
 )
 def test_fix_io_open(s, expected):
-    assert _fix_py3_plus(s) == expected
+    assert _fix_py3_plus(s, (3,)) == expected
diff --git a/tests/main_test.py b/tests/main_test.py
index afabb17..770b29c 100644
--- a/tests/main_test.py
+++ b/tests/main_test.py
@@ -129,6 +129,27 @@ def test_py37_plus_removes_annotations(tmpdir):
     assert f.read() == 'x = 1\n'
 
 
+def test_py38_plus_removes_no_arg_decorators(tmpdir):
+    f = tmpdir.join('f.py')
+    f.write(
+        'import functools\n\n'
+        '@functools.lru_cache()\n'
+        'def expensive():\n'
+        '   ...',
+    )
+    assert main((f.strpath,)) == 0
+    assert main((f.strpath, '--py3-plus')) == 0
+    assert main((f.strpath, '--py36-plus')) == 0
+    assert main((f.strpath, '--py37-plus')) == 0
+    assert main((f.strpath, '--py38-plus')) == 1
+    assert f.read() == (
+        'import functools\n\n'
+        '@functools.lru_cache\n'
+        'def expensive():\n'
+        '   ...'
+    )
+
+
 def test_noop_token_error(tmpdir):
     f = tmpdir.join('f.py')
     f.write(
diff --git a/tests/metaclass_type_test.py b/tests/metaclass_type_test.py
index 51b92c2..1c05eb5 100644
--- a/tests/metaclass_type_test.py
+++ b/tests/metaclass_type_test.py
@@ -28,7 +28,7 @@ from pyupgrade import _fix_py3_plus
     ),
 )
 def test_metaclass_type_assignment_noop(s):
-    assert _fix_py3_plus(s) == s
+    assert _fix_py3_plus(s, (3,)) == s
 
 
 @pytest.mark.parametrize(
@@ -54,4 +54,4 @@ def test_metaclass_type_assignment_noop(s):
     ),
 )
 def test_fix_metaclass_type_assignment(s, expected):
-    assert _fix_py3_plus(s) == expected
+    assert _fix_py3_plus(s, (3,)) == expected
diff --git a/tests/native_literals_test.py b/tests/native_literals_test.py
index 2e819d8..e3cd242 100644
--- a/tests/native_literals_test.py
+++ b/tests/native_literals_test.py
@@ -14,7 +14,7 @@ from pyupgrade import _fix_py3_plus
     ),
 )
 def test_fix_native_literals_noop(s):
-    assert _fix_py3_plus(s) == s
+    assert _fix_py3_plus(s, (3,)) == s
 
 
 @pytest.mark.parametrize(
@@ -29,4 +29,4 @@ def test_fix_native_literals_noop(s):
     ),
 )
 def test_fix_native_literals(s, expected):
-    assert _fix_py3_plus(s) == expected
+    assert _fix_py3_plus(s, (3,)) == expected
diff --git a/tests/new_style_classes_test.py b/tests/new_style_classes_test.py
index b6dac4d..78f9384 100644
--- a/tests/new_style_classes_test.py
+++ b/tests/new_style_classes_test.py
@@ -13,7 +13,7 @@ from pyupgrade import _fix_py3_plus
     ),
 )
 def test_fix_classes_noop(s):
-    assert _fix_py3_plus(s) == s
+    assert _fix_py3_plus(s, (3,)) == s
 
 
 @pytest.mark.parametrize(
@@ -101,4 +101,4 @@ def test_fix_classes_noop(s):
     ),
 )
 def test_fix_classes(s, expected):
-    assert _fix_py3_plus(s) == expected
+    assert _fix_py3_plus(s, (3,)) == expected
diff --git a/tests/no_arg_decorators_test.py b/tests/no_arg_decorators_test.py
new file mode 100644
index 0000000..b56ab5d
--- /dev/null
+++ b/tests/no_arg_decorators_test.py
@@ -0,0 +1,75 @@
+import pytest
+
+from pyupgrade import _fix_py3_plus
+
+
+@pytest.mark.parametrize(
+    ('s', 'min_version'),
+    (
+        pytest.param(
+            'from functools import lru_cache as lru_cache2\n\n'
+            '@lru_cache2()\n'
+            'def foo():\n'
+            '    pass\n',
+            (3, 8),
+            id='not following as imports',
+        ),
+        pytest.param(
+            'from functools import lru_cache\n\n'
+            '@lru_cache(max_size=1024)\n'
+            'def foo():\n'
+            '    pass\n',
+            (3, 8),
+            id='not rewriting calls with args',
+        ),
+        pytest.param(
+            'from functools2 import lru_cache\n\n'
+            '@lru_cache()\n'
+            'def foo():\n'
+            '    pass\n',
+            (3, 8),
+            id='not following unknown import',
+        ),
+        pytest.param(
+            'from functools import lru_cache\n\n'
+            '@lru_cache()\n'
+            'def foo():\n'
+            '    pass\n',
+            (3,),
+            id='not rewriting below 3.8',
+        ),
+    ),
+)
+def test_fix_no_arg_decorators_noop(s, min_version):
+    assert _fix_py3_plus(s, min_version) == s
+
+
+@pytest.mark.parametrize(
+    ('s', 'expected'),
+    (
+        pytest.param(
+            'from functools import lru_cache\n\n'
+            '@lru_cache()\n'
+            'def foo():\n'
+            '    pass\n',
+            'from functools import lru_cache\n\n'
+            '@lru_cache\n'
+            'def foo():\n'
+            '    pass\n',
+            id='call without attr',
+        ),
+        pytest.param(
+            'import functools\n\n'
+            '@functools.lru_cache()\n'
+            'def foo():\n'
+            '    pass\n',
+            'import functools\n\n'
+            '@functools.lru_cache\n'
+            'def foo():\n'
+            '    pass\n',
+            id='call with attr',
+        ),
+    ),
+)
+def test_fix_no_arg_decorators(s, expected):
+    assert _fix_py3_plus(s, (3, 8)) == expected
diff --git a/tests/open_mode_test.py b/tests/open_mode_test.py
index 7017014..dd306a8 100644
--- a/tests/open_mode_test.py
+++ b/tests/open_mode_test.py
@@ -16,7 +16,7 @@ from pyupgrade import _fix_py3_plus
     ),
 )
 def test_fix_open_mode_noop(s):
-    assert _fix_py3_plus(s) == s
+    assert _fix_py3_plus(s, (3,)) == s
 
 
 @pytest.mark.parametrize(
@@ -32,4 +32,4 @@ def test_fix_open_mode_noop(s):
     ),
 )
 def test_fix_open_mode(s, expected):
-    assert _fix_py3_plus(s) == expected
+    assert _fix_py3_plus(s, (3,)) == expected
diff --git a/tests/oserror_aliases_test.py b/tests/oserror_aliases_test.py
index 7ce6c70..345a1c7 100644
--- a/tests/oserror_aliases_test.py
+++ b/tests/oserror_aliases_test.py
@@ -67,7 +67,7 @@ from pyupgrade import FindPy3Plus
 )
 def test_fix_oserror_aliases_try(alias, tpl, expected):
     s = tpl.format(alias=alias)
-    assert _fix_py3_plus(s) == expected
+    assert _fix_py3_plus(s, (3,)) == expected
 
 
 @pytest.mark.parametrize(
@@ -103,7 +103,7 @@ def test_fix_oserror_aliases_try(alias, tpl, expected):
     ),
 )
 def test_fix_oserror_aliases_noop(s):
-    assert _fix_py3_plus(s) == s
+    assert _fix_py3_plus(s, (3,)) == s
 
 
 @pytest.mark.parametrize('imp', FindPy3Plus.OS_ERROR_ALIAS_MODULES)
@@ -123,7 +123,7 @@ def test_fix_oserror_aliases_noop(s):
 )
 def test_fix_oserror_aliases_noop_tpl(imp, tpl):
     s = tpl.format(imp=imp)
-    assert _fix_py3_plus(s) == s
+    assert _fix_py3_plus(s, (3,)) == s
 
 
 @pytest.mark.parametrize('imp', FindPy3Plus.OS_ERROR_ALIAS_MODULES)
@@ -358,7 +358,7 @@ def test_fix_oserror_aliases_noop_tpl(imp, tpl):
 )
 def test_fix_oserror_complex_aliases_try(imp, tpl, expected_tpl):
     s, expected = tpl.format(imp=imp), expected_tpl.format(imp=imp)
-    assert _fix_py3_plus(s) == expected
+    assert _fix_py3_plus(s, (3,)) == expected
 
 
 @pytest.mark.parametrize('alias', FindPy3Plus.OS_ERROR_ALIASES)
@@ -383,7 +383,7 @@ def test_fix_oserror_complex_aliases_try(imp, tpl, expected_tpl):
 )
 def test_fix_oserror_aliases_raise(alias, tpl, expected):
     s = tpl.format(alias=alias)
-    assert _fix_py3_plus(s) == expected
+    assert _fix_py3_plus(s, (3,)) == expected
 
 
 @pytest.mark.parametrize('imp', FindPy3Plus.OS_ERROR_ALIAS_MODULES)
@@ -476,4 +476,4 @@ def test_fix_oserror_aliases_raise(alias, tpl, expected):
 )
 def test_fix_oserror_complex_aliases_raise(imp, tpl, expected_tpl):
     s, expected = tpl.format(imp=imp), expected_tpl.format(imp=imp)
-    assert _fix_py3_plus(s) == expected
+    assert _fix_py3_plus(s, (3,)) == expected
diff --git a/tests/six_test.py b/tests/six_test.py
index 1688dd2..ca4c319 100644
--- a/tests/six_test.py
+++ b/tests/six_test.py
@@ -39,7 +39,7 @@ from pyupgrade import _fix_py3_plus
     ),
 )
 def test_fix_six_noop(s):
-    assert _fix_py3_plus(s) == s
+    assert _fix_py3_plus(s, (3,)) == s
 
 
 @pytest.mark.parametrize(
@@ -368,7 +368,7 @@ def test_fix_six_noop(s):
     ),
 )
 def test_fix_six(s, expected):
-    assert _fix_py3_plus(s) == expected
+    assert _fix_py3_plus(s, (3,)) == expected
 
 
 @pytest.mark.parametrize(
@@ -419,4 +419,4 @@ def test_fix_six(s, expected):
     ),
 )
 def test_fix_base_classes(s, expected):
-    assert _fix_py3_plus(s) == expected
+    assert _fix_py3_plus(s, (3,)) == expected
diff --git a/tests/super_test.py b/tests/super_test.py
index 504b1c7..7ec0095 100644
--- a/tests/super_test.py
+++ b/tests/super_test.py
@@ -58,7 +58,7 @@ from pyupgrade import _fix_py3_plus
     ),
 )
 def test_fix_super_noop(s):
-    assert _fix_py3_plus(s) == s
+    assert _fix_py3_plus(s, (3,)) == s
 
 
 @pytest.mark.parametrize(
@@ -120,4 +120,4 @@ def test_fix_super_noop(s):
     ),
 )
 def test_fix_super(s, expected):
-    assert _fix_py3_plus(s) == expected
+    assert _fix_py3_plus(s, (3,)) == expected
diff --git a/tests/versioned_branches_test.py b/tests/versioned_branches_test.py
index 8d6cafe..17bfd72 100644
--- a/tests/versioned_branches_test.py
+++ b/tests/versioned_branches_test.py
@@ -36,7 +36,7 @@ from pyupgrade import _fix_py3_plus
     ),
 )
 def test_fix_py2_block_noop(s):
-    assert _fix_py3_plus(s) == s
+    assert _fix_py3_plus(s, (3,)) == s
 
 
 @pytest.mark.parametrize(
@@ -390,7 +390,7 @@ def test_fix_py2_block_noop(s):
     ),
 )
 def test_fix_py2_blocks(s, expected):
-    assert _fix_py3_plus(s) == expected
+    assert _fix_py3_plus(s, (3,)) == expected
 
 
 @pytest.mark.parametrize(
@@ -406,4 +406,4 @@ def test_fix_py2_blocks(s, expected):
     ),
 )
 def test_fix_py3_only_code(s, expected):
-    assert _fix_py3_plus(s) == expected
+    assert _fix_py3_plus(s, (3,)) == expected
diff --git a/tests/yield_from_test.py b/tests/yield_from_test.py
index a6c1473..ce70bfb 100644
--- a/tests/yield_from_test.py
+++ b/tests/yield_from_test.py
@@ -130,7 +130,7 @@ from pyupgrade import targets_same
     ),
 )
 def test_fix_yield_from(s, expected):
-    assert _fix_py3_plus(s) == expected
+    assert _fix_py3_plus(s, (3,)) == expected
 
 
 @pytest.mark.parametrize(
@@ -169,7 +169,7 @@ def test_fix_yield_from(s, expected):
     ),
 )
 def test_fix_yield_from_noop(s):
-    assert _fix_py3_plus(s) == s
+    assert _fix_py3_plus(s, (3,)) == s
 
 
 def test_targets_same():