diff --git a/README.md b/README.md index 2de1ba4a..3ed661f5 100644 --- a/README.md +++ b/README.md @@ -683,6 +683,8 @@ Availability: +f'{foo.bar} {baz.womp}' -'{} {}'.format(f(), g()) +f'{f()} {g()}' +-'{x}'.format(**locals()) ++f'{x}' ``` _note_: `pyupgrade` is intentionally timid and will not create an f-string diff --git a/pyupgrade/_plugins/format_locals.py b/pyupgrade/_plugins/format_locals.py new file mode 100644 index 00000000..8c736ec6 --- /dev/null +++ b/pyupgrade/_plugins/format_locals.py @@ -0,0 +1,49 @@ +import ast +from typing import Iterable +from typing import List +from typing import Tuple + +from tokenize_rt import Offset +from tokenize_rt import rfind_string_parts +from tokenize_rt import Token + +from pyupgrade._ast_helpers import ast_to_offset +from pyupgrade._data import register +from pyupgrade._data import State +from pyupgrade._data import TokenFunc +from pyupgrade._token_helpers import find_closing_bracket +from pyupgrade._token_helpers import find_open_paren +from pyupgrade._token_helpers import find_token + + +def _fix(i: int, tokens: List[Token]) -> None: + dot_pos = find_token(tokens, i, '.') + open_pos = find_open_paren(tokens, dot_pos) + close_pos = find_closing_bracket(tokens, open_pos) + for string_idx in rfind_string_parts(tokens, dot_pos - 1): + tok = tokens[string_idx] + tokens[string_idx] = tok._replace(src=f'f{tok.src}') + del tokens[dot_pos:close_pos + 1] + + +@register(ast.Call) +def visit_Call( + state: State, + node: ast.Call, + parent: ast.AST, +) -> Iterable[Tuple[Offset, TokenFunc]]: + if ( + state.settings.min_version >= (3, 6) and + isinstance(node.func, ast.Attribute) and + isinstance(node.func.value, ast.Str) and + node.func.attr == 'format' and + len(node.args) == 0 and + len(node.keywords) == 1 and + node.keywords[0].arg is None and + isinstance(node.keywords[0].value, ast.Call) and + isinstance(node.keywords[0].value.func, ast.Name) and + node.keywords[0].value.func.id == 'locals' and + len(node.keywords[0].value.args) == 0 and + len(node.keywords[0].value.keywords) == 0 + ): + yield ast_to_offset(node), _fix diff --git a/tests/features/format_locals_test.py b/tests/features/format_locals_test.py new file mode 100644 index 00000000..b761841b --- /dev/null +++ b/tests/features/format_locals_test.py @@ -0,0 +1,53 @@ +import pytest + +from pyupgrade._data import Settings +from pyupgrade._main import _fix_plugins + + +@pytest.mark.parametrize( + ('s', 'version'), + ( + pytest.param( + '"{x}".format(**locals())', + (3,), + id='not 3.6+', + ), + pytest.param( + '"{x} {y}".format(x, **locals())', + (3, 6), + id='mixed locals() and params', + ), + ), +) +def test_fix_format_locals_noop(s, version): + assert _fix_plugins(s, settings=Settings(min_version=version)) == s + + +@pytest.mark.parametrize( + ('s', 'expected'), + ( + pytest.param( + '"{x}".format(**locals())', + 'f"{x}"', + id='normal case', + ), + pytest.param( + '"{x}" "{y}".format(**locals())', + 'f"{x}" f"{y}"', + id='joined strings', + ), + pytest.param( + '(\n' + ' "{x}"\n' + ' "{y}"\n' + ').format(**locals())\n', + '(\n' + ' f"{x}"\n' + ' f"{y}"\n' + ')\n', + id='joined strings with parens', + ), + ), +) +def test_fix_format_locals(s, expected): + assert _fix_plugins(s, settings=Settings(min_version=(3, 6))) == expected diff --git a/tests/features/fstrings_test.py b/tests/features/fstrings_test.py index 61c2cc1c..425b84e2 100644 --- a/tests/features/fstrings_test.py +++ b/tests/features/fstrings_test.py @@ -61,8 +61,6 @@ def test_fix_fstrings_noop(s): r'f"\N{snowman} {a}"', id='named escape sequences', ), - # TODO: poor man's f-strings? - # '"{foo}".format(**locals())' ), ) def test_fix_fstrings(s, expected):