From 68782e29ec9907e2db569c5cc6365d1feaa93173 Mon Sep 17 00:00:00 2001 From: Friday Date: Wed, 18 Feb 2026 09:52:53 +0000 Subject: [PATCH] Fix parsing of raw f-strings with backslash before brace In raw f-strings like rf"\{x}", the backslash is a literal character and should not prevent { from opening an f-string expression. The tokenizer was using the same regex for both raw and non-raw f-strings, where \{ was consumed as a backslash escape sequence. The fix: - Track whether an f-string has a raw prefix in FStringNode - Use separate regex patterns for raw f-strings that treat backslash as an ordinary character - Store the raw flag in fstring_pattern_map alongside the quote Fixes #207. --- parso/python/tokenize.py | 17 ++++++++++++++--- test/test_fstring.py | 10 ++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/parso/python/tokenize.py b/parso/python/tokenize.py index 64610ddb..53f8f1c4 100644 --- a/parso/python/tokenize.py +++ b/parso/python/tokenize.py @@ -119,6 +119,10 @@ def _get_token_collection(version_info): fstring_string_multi_line = _compile( r'(?:\{\{|\}\}|\\N\{' + unicode_character_name + r'\}|\\[^N]|[^{}\\])+' ) +# In raw f-strings, backslash is a literal character and does NOT escape +# the following character. So \{ means literal \ followed by expression {. +fstring_string_single_line_raw = _compile(r'(?:\{\{|\}\}|[^{}\r\n])+') +fstring_string_multi_line_raw = _compile(r'(?:\{\{|\}\}|[^{}])+') fstring_format_spec_single_line = _compile(r'(?:\\(?:\r\n?|\n)|[^{}\r\n])+') fstring_format_spec_multi_line = _compile(r'[^{}]+') @@ -219,7 +223,7 @@ def _create_token_collection(version_info): for t in fstring_prefixes: for quote in all_quotes: - fstring_pattern_map[t + quote] = quote + fstring_pattern_map[t + quote] = (quote, 'r' in t.lower()) ALWAYS_BREAK_TOKENS = (';', 'import', 'class', 'def', 'try', 'except', 'finally', 'while', 'with', 'return', 'continue', @@ -253,8 +257,9 @@ def __repr__(self): class FStringNode: - def __init__(self, quote): + def __init__(self, quote, is_raw=False): self.quote = quote + self.is_raw = is_raw self.parentheses_count = 0 self.previous_lines = '' self.last_string_start_pos: Any = None @@ -307,6 +312,11 @@ def _find_fstring_string(endpats, fstring_stack, line, lnum, pos): regex = fstring_format_spec_multi_line else: regex = fstring_format_spec_single_line + elif tos.is_raw: + if allow_multiline: + regex = fstring_string_multi_line_raw + else: + regex = fstring_string_single_line_raw else: if allow_multiline: regex = fstring_string_multi_line @@ -593,7 +603,8 @@ def dedent_if_necessary(start): else: # ordinary string yield PythonToken(STRING, token, spos, prefix) elif token in fstring_pattern_map: # The start of an fstring. - fstring_stack.append(FStringNode(fstring_pattern_map[token])) + quote, is_raw = fstring_pattern_map[token] + fstring_stack.append(FStringNode(quote, is_raw=is_raw)) yield PythonToken(FSTRING_START, token, spos, prefix) elif initial == '\\' and line[start:] in ('\\\n', '\\\r\n', '\\\r'): # continued stmt additional_prefix += prefix + line[start:] diff --git a/test/test_fstring.py b/test/test_fstring.py index c81d027a..0b807469 100644 --- a/test/test_fstring.py +++ b/test/test_fstring.py @@ -78,6 +78,16 @@ def grammar(): 'f"\\N{SOYOMBO LETTER -A}"', 'f"\\N{DOMINO TILE HORIZONTAL-00-00}"', 'f"""\\N{NO ENTRY}"""', + + # raw f-strings: backslash is literal, doesn't escape braces (#207) + 'rf"\\{x}"', + 'rf"\\{x:0>3}"', + 'rf"abc\\{x}def"', + 'rf"""\\{x}"""', + 'Rf"\\{x}"', + 'rF"\\{x}"', + 'fR"\\{x}"', + 'Fr"\\{x}"', ] ) def test_valid(code, grammar):