Repository: asottile/pyupgrade

Issue:
upgrade subprocess.run(universal_newlines=True) to subprocess.run(text=True) in --py37-plus

Discussion:

Patch:
diff --git a/README.md b/README.md
index 5e78494..82bf3f0 100644
--- a/README.md
+++ b/README.md
@@ -462,6 +462,18 @@ _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).
 
+
+### `subprocess.run`: replace `universal_newlines` with `text`
+
+Availability:
+- `--py37-plus` is passed on the commandline.
+
+```diff
+-output = subprocess.run(['foo'], universal_newlines=True)
++output = subprocess.run(['foo'], text=True)
+```
+
+
 ### remove parentheses from `@functools.lru_cache()`
 
 Availability:
diff --git a/pyupgrade/_data.py b/pyupgrade/_data.py
index 78923a9..6218282 100644
--- a/pyupgrade/_data.py
+++ b/pyupgrade/_data.py
@@ -49,6 +49,7 @@ RECORD_FROM_IMPORTS = frozenset((
     'select',
     'six',
     'socket',
+    'subprocess',
     'sys',
     'typing',
 ))
diff --git a/pyupgrade/_plugins/universal_newlines_to_text.py b/pyupgrade/_plugins/universal_newlines_to_text.py
new file mode 100644
index 0000000..02eea27
--- /dev/null
+++ b/pyupgrade/_plugins/universal_newlines_to_text.py
@@ -0,0 +1,61 @@
+import ast
+import functools
+from typing import Iterable
+from typing import List
+from typing import Tuple
+
+from tokenize_rt import Offset
+from tokenize_rt import Token
+from tokenize_rt import tokens_to_src
+
+from pyupgrade._ast_helpers import ast_to_offset
+from pyupgrade._ast_helpers import is_name_attr
+from pyupgrade._data import register
+from pyupgrade._data import State
+from pyupgrade._data import TokenFunc
+from pyupgrade._token_helpers import find_open_paren
+from pyupgrade._token_helpers import parse_call_args
+
+
+def _replace_universal_newlines_with_text(
+    i: int,
+    tokens: List[Token],
+    *,
+    arg_idx: int,
+) -> None:
+    j = find_open_paren(tokens, i)
+    func_args, _ = parse_call_args(tokens, j)
+    src = tokens_to_src(tokens[slice(*func_args[arg_idx])])
+    new_src = src.replace('universal_newlines', 'text', 1)
+    tokens[slice(*func_args[arg_idx])] = [Token('SRC', new_src)]
+
+
+@register(ast.Call)
+def visit_Call(
+        state: State,
+        node: ast.Call,
+        parent: ast.AST,
+) -> Iterable[Tuple[Offset, TokenFunc]]:
+    if (
+            state.settings.min_version >= (3, 7) and
+            is_name_attr(
+                node.func,
+                state.from_imports,
+                'subprocess',
+                ('run',),
+            )
+    ):
+        kwarg_idx = next(
+            (
+                n
+                for n, keyword in enumerate(node.keywords)
+                if keyword.arg == 'universal_newlines'
+            ),
+            None,
+        )
+        if kwarg_idx is not None:
+            func = functools.partial(
+                _replace_universal_newlines_with_text,
+                arg_idx=len(node.args) + kwarg_idx,
+            )
+            yield ast_to_offset(node), func


Test patch:
diff --git a/tests/features/universal_newlines_to_text_test.py b/tests/features/universal_newlines_to_text_test.py
new file mode 100644
index 0000000..8cdc005
--- /dev/null
+++ b/tests/features/universal_newlines_to_text_test.py
@@ -0,0 +1,77 @@
+import pytest
+
+from pyupgrade._data import Settings
+from pyupgrade._main import _fix_plugins
+
+
+@pytest.mark.parametrize(
+    ('s', 'version'),
+    (
+        pytest.param(
+            'import subprocess\n'
+            'subprocess.run(["foo"], universal_newlines=True)\n',
+            (3,),
+            id='not Python3.7+',
+        ),
+        pytest.param(
+            'from foo import run\n'
+            'run(["foo"], universal_newlines=True)\n',
+            (3, 7),
+            id='run imported, but not from subprocess',
+        ),
+        pytest.param(
+            'from subprocess import run\n'
+            'run(["foo"], shell=True)\n',
+            (3, 7),
+            id='universal_newlines not used',
+        ),
+    ),
+)
+def test_fix_universal_newlines_to_text_noop(s, version):
+    assert _fix_plugins(s, settings=Settings(min_version=version)) == s
+
+
+@pytest.mark.parametrize(
+    ('s', 'expected'),
+    (
+        pytest.param(
+            'import subprocess\n'
+            'subprocess.run(["foo"], universal_newlines=True)\n',
+
+            'import subprocess\n'
+            'subprocess.run(["foo"], text=True)\n',
+
+            id='subprocess.run attribute',
+        ),
+        pytest.param(
+            'from subprocess import run\n'
+            'run(["foo"], universal_newlines=True)\n',
+
+            'from subprocess import run\n'
+            'run(["foo"], text=True)\n',
+
+            id='run imported from subprocess',
+        ),
+        pytest.param(
+            'from subprocess import run\n'
+            'run(["foo"], universal_newlines=universal_newlines)\n',
+
+            'from subprocess import run\n'
+            'run(["foo"], text=universal_newlines)\n',
+
+            id='universal_newlines appears as value',
+        ),
+        pytest.param(
+            'from subprocess import run\n'
+            'run(["foo"], *foo, universal_newlines=universal_newlines)\n',
+
+            'from subprocess import run\n'
+            'run(["foo"], *foo, text=universal_newlines)\n',
+
+            id='with starargs',
+        ),
+    ),
+)
+def test_fix_universal_newlines_to_text(s, expected):
+    ret = _fix_plugins(s, settings=Settings(min_version=(3, 7)))
+    assert ret == expected