From d19241bbb390bd7d8bcc2c696445855f8a279b68 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Thu, 1 Aug 2024 11:08:06 +0100 Subject: [PATCH] Handle escaped braces in f-strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit To use a curly brace in an f-string, you must escape it. For example: >>> k = 1 >>> f'{{{k}' '{1' Saving this as a script and running the 'tokenize' module highlights something odd around the counting of tokens: ❯ python -m tokenize wow.py 0,0-0,0: ENCODING 'utf-8' 1,0-1,1: NAME 'k' 1,2-1,3: OP '=' 1,4-1,5: NUMBER '1' 1,5-1,6: NEWLINE '\n' 2,0-2,2: FSTRING_START "f'" 2,2-2,3: FSTRING_MIDDLE '{' # <-- here... 2,4-2,5: OP '{' # <-- and here 2,5-2,6: NAME 'k' 2,6-2,7: OP '}' 2,7-2,8: FSTRING_END "'" 2,8-2,9: NEWLINE '\n' 3,0-3,0: ENDMARKER '' The FSTRING_MIDDLE character we have is the escaped/post-parse single curly brace rather than the raw double curly brace, however, while our end index of this token accounts for the parsed form, the start index of the next token does not (put another way, it jumps from 3 -> 4). This triggers some existing, unrelated code that we need to bypass. Do just that. Signed-off-by: Stephen Finucane Closes: #1948 --- src/flake8/processor.py | 14 +++++++++++--- tests/integration/test_plugins.py | 14 ++++++++++---- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/flake8/processor.py b/src/flake8/processor.py index 21a25e0f..29c5929c 100644 --- a/src/flake8/processor.py +++ b/src/flake8/processor.py @@ -203,9 +203,17 @@ def build_logical_line_tokens(self) -> _Logical: # noqa: C901 if token_type == tokenize.STRING: text = mutate_string(text) elif token_type == FSTRING_MIDDLE: # pragma: >=3.12 cover - text = "x" * len(text) + # A curly brace in an FSTRING_MIDDLE token must be an escaped + # curly brace. Both 'text' and 'end' will account for the + # escaped version of the token (i.e. a single brace) rather + # than the raw double brace version, so we must counteract this + fstring_offset = 0 + if "{" in text or "}" in text: + fstring_offset = text.count("{") + text.count("}") + text = "x" * (len(text) + fstring_offset) + end = (end[0], end[1] + fstring_offset) if previous_row: - (start_row, start_column) = start + start_row, start_column = start if previous_row != start_row: row_index = previous_row - 1 column_index = previous_column - 1 @@ -219,7 +227,7 @@ def build_logical_line_tokens(self) -> _Logical: # noqa: C901 logical.append(text) length += len(text) mapping.append((length, end)) - (previous_row, previous_column) = end + previous_row, previous_column = end return comments, logical, mapping def build_ast(self) -> ast.AST: diff --git a/tests/integration/test_plugins.py b/tests/integration/test_plugins.py index d4c22b0b..1a331346 100644 --- a/tests/integration/test_plugins.py +++ b/tests/integration/test_plugins.py @@ -1,6 +1,8 @@ """Integration tests for plugin loading.""" from __future__ import annotations +import sys + import pytest from flake8.main.cli import main @@ -248,16 +250,20 @@ def test_logical_line_plugin(tmpdir, capsys): cfg.write(cfg_s) src = """\ -f'hello world' +f'{{"{hello}": "{world}"}}' """ t_py = tmpdir.join("t.py") t_py.write_binary(src.encode()) with tmpdir.as_cwd(): assert main(("t.py", "--config", str(cfg))) == 1 - - expected = """\ -t.py:1:1: T001 "f'xxxxxxxxxxx'" + if sys.version_info >= (3, 12): # pragma: >=3.12 cover + expected = """\ +t.py:1:1: T001 "f'xxx{hello}xxxx{world}xxx'" +""" + else: # pragma: <3.12 cover + expected = """\ +t.py:1:1: T001 "f'xxxxxxxxxxxxxxxxxxxxxxxx'" """ out, err = capsys.readouterr() assert out == expected