Repository: asottile/pyupgrade

Issue:
--py3-plus: io.open => open

Discussion:

Patch:
diff --git a/README.md b/README.md
index 567b484..cfa3def 100644
--- a/README.md
+++ b/README.md
@@ -284,6 +284,20 @@ six.assertRaisesRegex(self, e, r, fn)   # self.assertRaisesRegex(e, r, fn)
 six.assertRegex(self, s, r)             # self.assertRegex(s, r)
 ```
 
+### `open` alias
+
+Availability:
+- `--py3-plus` is passed on the commandline.
+
+```python
+# input
+with io.open('f.txt') as f:
+    pass
+# output
+with open('f.txt') as f:
+    pass
+```
+
 ### `OSError` aliases
 
 Availability:
diff --git a/pyupgrade.py b/pyupgrade.py
index d61250e..2124804 100644
--- a/pyupgrade.py
+++ b/pyupgrade.py
@@ -1115,6 +1115,7 @@ class FindPy3Plus(ast.NodeVisitor):
         self._from_imported_names = collections.defaultdict(
             set,
         )  # type: Dict[str, Set[str]]
+        self.io_open_calls = {}  # type: Dict[Offset, ast.Call]
         self.os_error_alias_calls = {}  # type: Dict[Offset, ast.Call]
         self.os_error_alias_simple = {}  # type: Dict[Offset, NameOrAttr]
 
@@ -1147,6 +1148,15 @@ class FindPy3Plus(ast.NodeVisitor):
             node.attr in names
         )
 
+    def _is_io_open(self, node):
+        # type: (ast.expr) -> bool
+        return (
+            isinstance(node, ast.Attribute) and
+            isinstance(node.value, ast.Name) and
+            node.value.id == 'io' and
+            node.attr == 'open'
+        )
+
     def _is_os_error_alias(self, node):
         # type: (ast.expr) -> bool
         return (
@@ -1352,6 +1362,8 @@ class FindPy3Plus(ast.NodeVisitor):
                 _is_codec(node.args[0].s, 'utf-8')
         ):
             self.encode_calls[_ast_to_offset(node)] = node
+        elif self._is_io_open(node.func):
+            self.io_open_calls[_ast_to_offset(node)] = node
 
         self.generic_visit(node)
 
@@ -1715,6 +1727,7 @@ def _fix_py3_plus(contents_text):  # type: (str) -> str
             visitor.if_py2_blocks,
             visitor.if_py3_blocks,
             visitor.native_literals,
+            visitor.io_open_calls,
             visitor.os_error_alias_calls,
             visitor.os_error_alias_simple,
             visitor.six_add_metaclass,
@@ -1858,6 +1871,9 @@ def _fix_py3_plus(contents_text):  # type: (str) -> str
                 _replace_call(tokens, i, end, func_args, '{args[0]}')
             else:
                 tokens[i:end] = [token._replace(name='STRING', src="''")]
+        elif token.offset in visitor.io_open_calls:
+            j = _find_open_paren(tokens, i)
+            tokens[i:j] = [token._replace(name='NAME', src='open')]
         elif token.offset in visitor.os_error_alias_calls:
             j = _find_open_paren(tokens, i)
             tokens[i:j] = [token._replace(name='NAME', src='OSError')]


Test patch:
diff --git a/tests/io_open_test.py b/tests/io_open_test.py
new file mode 100644
index 0000000..231dec9
--- /dev/null
+++ b/tests/io_open_test.py
@@ -0,0 +1,38 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+from __future__ import unicode_literals
+
+import pytest
+
+from pyupgrade import _fix_py3_plus
+
+
+@pytest.mark.parametrize(
+    's',
+    (
+        # when using open without referencing io we don't need to rewrite
+        'from io import open\n\n'
+        'with open("f.txt") as f:\n'
+        '     print(f.read())\n',
+    ),
+)
+def test_fix_io_open_noop(s):
+    assert _fix_py3_plus(s) == s
+
+
+@pytest.mark.parametrize(
+    ('s', 'expected'),
+    (
+        (
+            'import io\n\n'
+            'with io.open("f.txt", mode="r", buffering=-1, **kwargs) as f:\n'
+            '   print(f.read())\n',
+
+            'import io\n\n'
+            'with open("f.txt", mode="r", buffering=-1, **kwargs) as f:\n'
+            '   print(f.read())\n',
+        ),
+    ),
+)
+def test_fix_io_open(s, expected):
+    assert _fix_py3_plus(s) == expected