diff --git a/AUTHORS.txt b/AUTHORS.txt index 451918f..7c4a1c8 100644 --- a/AUTHORS.txt +++ b/AUTHORS.txt @@ -56,4 +56,6 @@ Batuhan Taskaya (@isidentical) Jocelyn Boullier (@Kazy) +Sarbajit Sengupta (@Sarbajit-Sengupta) + Note: (@user) means a github user name. diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a4811fb..251d7aa 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,9 @@ Changelog Unreleased ++++++++++ +- Fix f-string tokenization when a backslash precedes a replacement-field + brace, including raw f-strings with format specifications (#207). + 0.8.7 (2026-05-02) ++++++++++++++++++ diff --git a/parso/python/tokenize.py b/parso/python/tokenize.py index 64610dd..f4627ae 100644 --- a/parso/python/tokenize.py +++ b/parso/python/tokenize.py @@ -114,10 +114,10 @@ def _get_token_collection(version_info): unicode_character_name = r'[A-Za-z0-9\-]+(?: [A-Za-z0-9\-]+)*' fstring_string_single_line = _compile( r'(?:\{\{|\}\}|\\N\{' + unicode_character_name - + r'\}|\\(?:\r\n?|\n)|\\[^\r\nN]|[^{}\r\n\\])+' + + r'\}|\\(?:\r\n?|\n)|\\[^\r\nN{}]|\\(?=[{}])|[^{}\r\n\\])+' ) fstring_string_multi_line = _compile( - r'(?:\{\{|\}\}|\\N\{' + unicode_character_name + r'\}|\\[^N]|[^{}\\])+' + r'(?:\{\{|\}\}|\\N\{' + unicode_character_name + r'\}|\\[^N{}]|\\(?=[{}])|[^{}\\])+' ) fstring_format_spec_single_line = _compile(r'(?:\\(?:\r\n?|\n)|[^{}\r\n])+') fstring_format_spec_multi_line = _compile(r'[^{}]+') diff --git a/test/test_fstring_backslash_braces.py b/test/test_fstring_backslash_braces.py new file mode 100644 index 0000000..c69d20a --- /dev/null +++ b/test/test_fstring_backslash_braces.py @@ -0,0 +1,31 @@ +"""A backslash does not escape replacement-field braces in f-strings.""" + +import ast + +import pytest + +import parso +from parso.parser import ParserSyntaxError + + +@pytest.mark.parametrize('prefix', ['rf', 'fr', 'RF', 'FR']) +@pytest.mark.parametrize('quote', ['"', "'", '"""', "'''"]) +@pytest.mark.parametrize('slashes', [1, 2, 3]) +def test_raw_fstring_backslash_before_field(prefix, quote, slashes): + code = prefix + quote + '\\' * slashes + '{value:0>3o}' + quote + ast.parse(code) + grammar = parso.load_grammar(version='3.13') + module = grammar.parse(code, error_recovery=False) + assert module.get_code() == code + assert not list(grammar.iter_errors(module)) + assert any(leaf.value == 'value' for leaf in module.get_used_names()['value']) + + +@pytest.mark.parametrize('prefix', ['rf', 'fr']) +@pytest.mark.parametrize('quote', ['"', '"""']) +def test_backslash_cannot_escape_unmatched_closing_brace(prefix, quote): + code = prefix + quote + '\\}' + quote + with pytest.raises(SyntaxError): + ast.parse(code) + with pytest.raises(ParserSyntaxError): + parso.parse(code, version='3.13', error_recovery=False)