Skip to content

Commit dcf31c9

Browse files
authored
[syntax-errors] PEP 701 f-strings before Python 3.12 (#16543)
## Summary This PR detects the use of PEP 701 f-strings before 3.12. This one sounded difficult and ended up being pretty easy, so I think there's a good chance I've over-simplified things. However, from experimenting in the Python REPL and checking with [pyright], I think this is correct. pyright actually doesn't even flag the comment case, but Python does. I also checked pyright's implementation for [quotes](https://github.com/microsoft/pyright/blob/98dc4469cc5126bcdcbaf396a6c9d7e75dc1c4a0/packages/pyright-internal/src/analyzer/checker.ts#L1379-L1398) and [escapes](https://github.com/microsoft/pyright/blob/98dc4469cc5126bcdcbaf396a6c9d7e75dc1c4a0/packages/pyright-internal/src/analyzer/checker.ts#L1365-L1377) and think I've approximated how they do it. Python's error messages also point to the simple approach of these characters simply not being allowed: ```pycon Python 3.11.11 (main, Feb 12 2025, 14:51:05) [Clang 19.1.6 ] on linux Type "help", "copyright", "credits" or "license" for more information. >>> f'''multiline { ... expression # comment ... }''' File "<stdin>", line 3 }''' ^ SyntaxError: f-string expression part cannot include '#' >>> f'''{not a line \ ... continuation}''' File "<stdin>", line 2 continuation}''' ^ SyntaxError: f-string expression part cannot include a backslash >>> f'hello {'world'}' File "<stdin>", line 1 f'hello {'world'}' ^^^^^ SyntaxError: f-string: expecting '}' ``` And since escapes aren't allowed, I don't think there are any tricky cases where nested quotes or comments can sneak in. It's also slightly annoying that the error is repeated for every nested quote character, but that also mirrors pyright, although they highlight the whole nested string, which is a little nicer. However, their check is in the analysis phase, so I don't think we have such easy access to the quoted range, at least without adding another mini visitor. ## Test Plan New inline tests [pyright]: https://pyright-play.net/?pythonVersion=3.11&strict=true&code=EYQw5gBAvBAmCWBjALgCgO4gHaygRgEoAoEaCAIgBpyiiBiCLAUwGdknYIBHAVwHt2LIgDMA5AFlwSCJhwAuCAG8IoMAG1Rs2KIC6EAL6iIxosbPmLlq5foRWiEAAcmERAAsQAJxAomnltY2wuSKogA6WKIAdABWfPBYqCAE%2BuSBVqbpWVm2iHwAtvlMWMgB2ekiolUAgq4FjgA2TAAeEMieSADWCsoV5qoaqrrGDJ5MiDz%2B8ABuLqosAIREhlXlaybrmyYMXsDw7V4AnoysyAmQ5SIhwYo3d9cheADUeKlv5O%2BpQA
1 parent 4ab5298 commit dcf31c9

File tree

11 files changed

+2223
-3
lines changed

11 files changed

+2223
-3
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"target_version": "3.12"}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# parse_options: {"target-version": "3.11"}
2+
f'Magic wand: { bag['wand'] }' # nested quotes
3+
f"{'\n'.join(a)}" # escape sequence
4+
f'''A complex trick: {
5+
bag['bag'] # comment
6+
}'''
7+
f"{f"{f"{f"{f"{f"{1+1}"}"}"}"}"}" # arbitrary nesting
8+
f"{f'''{"nested"} inner'''} outer" # nested (triple) quotes
9+
f"test {a \
10+
} more" # line continuation
11+
f"""{f"""{x}"""}""" # mark the whole triple quote
12+
f"{'\n'.join(['\t', '\v', '\r'])}" # multiple escape sequences, multiple errors
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# parse_options: {"target-version": "3.11"}
2+
f"outer {'# not a comment'}"
3+
f'outer {x:{"# not a comment"} }'
4+
f"""{f'''{f'{"# not a comment"}'}'''}"""
5+
f"""{f'''# before expression {f'# aro{f"#{1+1}#"}und #'}'''} # after expression"""
6+
f"escape outside of \t {expr}\n"
7+
f"test\"abcd"
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# parse_options: {"target-version": "3.12"}
2+
f'Magic wand: { bag['wand'] }' # nested quotes
3+
f"{'\n'.join(a)}" # escape sequence
4+
f'''A complex trick: {
5+
bag['bag'] # comment
6+
}'''
7+
f"{f"{f"{f"{f"{f"{1+1}"}"}"}"}"}" # arbitrary nesting
8+
f"{f'''{"nested"} inner'''} outer" # nested (triple) quotes
9+
f"test {a \
10+
} more" # line continuation

crates/ruff_python_parser/src/error.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,14 @@ pub enum StarTupleKind {
452452
Yield,
453453
}
454454

455+
/// The type of PEP 701 f-string error for [`UnsupportedSyntaxErrorKind::Pep701FString`].
456+
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
457+
pub enum FStringKind {
458+
Backslash,
459+
Comment,
460+
NestedQuote,
461+
}
462+
455463
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
456464
pub enum UnparenthesizedNamedExprKind {
457465
SequenceIndex,
@@ -661,6 +669,34 @@ pub enum UnsupportedSyntaxErrorKind {
661669
TypeAliasStatement,
662670
TypeParamDefault,
663671

672+
/// Represents the use of a [PEP 701] f-string before Python 3.12.
673+
///
674+
/// ## Examples
675+
///
676+
/// As described in the PEP, each of these cases were invalid before Python 3.12:
677+
///
678+
/// ```python
679+
/// # nested quotes
680+
/// f'Magic wand: { bag['wand'] }'
681+
///
682+
/// # escape characters
683+
/// f"{'\n'.join(a)}"
684+
///
685+
/// # comments
686+
/// f'''A complex trick: {
687+
/// bag['bag'] # recursive bags!
688+
/// }'''
689+
///
690+
/// # arbitrary nesting
691+
/// f"{f"{f"{f"{f"{f"{1+1}"}"}"}"}"}"
692+
/// ```
693+
///
694+
/// These restrictions were lifted in Python 3.12, meaning that all of these examples are now
695+
/// valid.
696+
///
697+
/// [PEP 701]: https://peps.python.org/pep-0701/
698+
Pep701FString(FStringKind),
699+
664700
/// Represents the use of a parenthesized `with` item before Python 3.9.
665701
///
666702
/// ## Examples
@@ -838,6 +874,15 @@ impl Display for UnsupportedSyntaxError {
838874
UnsupportedSyntaxErrorKind::TypeParamDefault => {
839875
"Cannot set default type for a type parameter"
840876
}
877+
UnsupportedSyntaxErrorKind::Pep701FString(FStringKind::Backslash) => {
878+
"Cannot use an escape sequence (backslash) in f-strings"
879+
}
880+
UnsupportedSyntaxErrorKind::Pep701FString(FStringKind::Comment) => {
881+
"Cannot use comments in f-strings"
882+
}
883+
UnsupportedSyntaxErrorKind::Pep701FString(FStringKind::NestedQuote) => {
884+
"Cannot reuse outer quote character in f-strings"
885+
}
841886
UnsupportedSyntaxErrorKind::ParenthesizedContextManager => {
842887
"Cannot use parentheses within a `with` statement"
843888
}
@@ -904,6 +949,7 @@ impl UnsupportedSyntaxErrorKind {
904949
UnsupportedSyntaxErrorKind::TypeParameterList => Change::Added(PythonVersion::PY312),
905950
UnsupportedSyntaxErrorKind::TypeAliasStatement => Change::Added(PythonVersion::PY312),
906951
UnsupportedSyntaxErrorKind::TypeParamDefault => Change::Added(PythonVersion::PY313),
952+
UnsupportedSyntaxErrorKind::Pep701FString(_) => Change::Added(PythonVersion::PY312),
907953
UnsupportedSyntaxErrorKind::ParenthesizedContextManager => {
908954
Change::Added(PythonVersion::PY39)
909955
}

crates/ruff_python_parser/src/parser/expression.rs

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@ use ruff_python_ast::{
1111
};
1212
use ruff_text_size::{Ranged, TextLen, TextRange, TextSize};
1313

14-
use crate::error::{StarTupleKind, UnparenthesizedNamedExprKind};
14+
use crate::error::{FStringKind, StarTupleKind, UnparenthesizedNamedExprKind};
1515
use crate::parser::progress::ParserProgress;
1616
use crate::parser::{helpers, FunctionKind, Parser};
1717
use crate::string::{parse_fstring_literal_element, parse_string_literal, StringType};
1818
use crate::token::{TokenKind, TokenValue};
1919
use crate::token_set::TokenSet;
20-
use crate::{FStringErrorType, Mode, ParseErrorType, UnsupportedSyntaxErrorKind};
20+
use crate::{
21+
FStringErrorType, Mode, ParseErrorType, UnsupportedSyntaxError, UnsupportedSyntaxErrorKind,
22+
};
2123

2224
use super::{FStringElementsKind, Parenthesized, RecoveryContextKind};
2325

@@ -1393,13 +1395,89 @@ impl<'src> Parser<'src> {
13931395

13941396
self.expect(TokenKind::FStringEnd);
13951397

1398+
// test_ok pep701_f_string_py312
1399+
// # parse_options: {"target-version": "3.12"}
1400+
// f'Magic wand: { bag['wand'] }' # nested quotes
1401+
// f"{'\n'.join(a)}" # escape sequence
1402+
// f'''A complex trick: {
1403+
// bag['bag'] # comment
1404+
// }'''
1405+
// f"{f"{f"{f"{f"{f"{1+1}"}"}"}"}"}" # arbitrary nesting
1406+
// f"{f'''{"nested"} inner'''} outer" # nested (triple) quotes
1407+
// f"test {a \
1408+
// } more" # line continuation
1409+
1410+
// test_ok pep701_f_string_py311
1411+
// # parse_options: {"target-version": "3.11"}
1412+
// f"outer {'# not a comment'}"
1413+
// f'outer {x:{"# not a comment"} }'
1414+
// f"""{f'''{f'{"# not a comment"}'}'''}"""
1415+
// f"""{f'''# before expression {f'# aro{f"#{1+1}#"}und #'}'''} # after expression"""
1416+
// f"escape outside of \t {expr}\n"
1417+
// f"test\"abcd"
1418+
1419+
// test_err pep701_f_string_py311
1420+
// # parse_options: {"target-version": "3.11"}
1421+
// f'Magic wand: { bag['wand'] }' # nested quotes
1422+
// f"{'\n'.join(a)}" # escape sequence
1423+
// f'''A complex trick: {
1424+
// bag['bag'] # comment
1425+
// }'''
1426+
// f"{f"{f"{f"{f"{f"{1+1}"}"}"}"}"}" # arbitrary nesting
1427+
// f"{f'''{"nested"} inner'''} outer" # nested (triple) quotes
1428+
// f"test {a \
1429+
// } more" # line continuation
1430+
// f"""{f"""{x}"""}""" # mark the whole triple quote
1431+
// f"{'\n'.join(['\t', '\v', '\r'])}" # multiple escape sequences, multiple errors
1432+
1433+
let range = self.node_range(start);
1434+
1435+
if !self.options.target_version.supports_pep_701() {
1436+
let quote_bytes = flags.quote_str().as_bytes();
1437+
let quote_len = flags.quote_len();
1438+
for expr in elements.expressions() {
1439+
for slash_position in memchr::memchr_iter(b'\\', self.source[expr.range].as_bytes())
1440+
{
1441+
let slash_position = TextSize::try_from(slash_position).unwrap();
1442+
self.add_unsupported_syntax_error(
1443+
UnsupportedSyntaxErrorKind::Pep701FString(FStringKind::Backslash),
1444+
TextRange::at(expr.range.start() + slash_position, '\\'.text_len()),
1445+
);
1446+
}
1447+
1448+
if let Some(quote_position) =
1449+
memchr::memmem::find(self.source[expr.range].as_bytes(), quote_bytes)
1450+
{
1451+
let quote_position = TextSize::try_from(quote_position).unwrap();
1452+
self.add_unsupported_syntax_error(
1453+
UnsupportedSyntaxErrorKind::Pep701FString(FStringKind::NestedQuote),
1454+
TextRange::at(expr.range.start() + quote_position, quote_len),
1455+
);
1456+
};
1457+
}
1458+
1459+
self.check_fstring_comments(range);
1460+
}
1461+
13961462
ast::FString {
13971463
elements,
1398-
range: self.node_range(start),
1464+
range,
13991465
flags: ast::FStringFlags::from(flags),
14001466
}
14011467
}
14021468

1469+
/// Check `range` for comment tokens and report an `UnsupportedSyntaxError` for each one found.
1470+
fn check_fstring_comments(&mut self, range: TextRange) {
1471+
self.unsupported_syntax_errors
1472+
.extend(self.tokens.in_range(range).iter().filter_map(|token| {
1473+
token.kind().is_comment().then_some(UnsupportedSyntaxError {
1474+
kind: UnsupportedSyntaxErrorKind::Pep701FString(FStringKind::Comment),
1475+
range: token.range(),
1476+
target_version: self.options.target_version,
1477+
})
1478+
}));
1479+
}
1480+
14031481
/// Parses a list of f-string elements.
14041482
///
14051483
/// # Panics

crates/ruff_python_parser/src/token.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,12 @@ impl TokenKind {
418418
matches!(self, TokenKind::Comment | TokenKind::NonLogicalNewline)
419419
}
420420

421+
/// Returns `true` if this is a comment token.
422+
#[inline]
423+
pub const fn is_comment(&self) -> bool {
424+
matches!(self, TokenKind::Comment)
425+
}
426+
421427
#[inline]
422428
pub const fn is_arithmetic(self) -> bool {
423429
matches!(

crates/ruff_python_parser/src/token_source.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,21 @@ impl<'src> TokenSource<'src> {
166166
self.tokens.truncate(tokens_position);
167167
}
168168

169+
/// Returns a slice of [`Token`] that are within the given `range`.
170+
pub(crate) fn in_range(&self, range: TextRange) -> &[Token] {
171+
let start = self
172+
.tokens
173+
.iter()
174+
.rposition(|tok| tok.start() == range.start());
175+
let end = self.tokens.iter().rposition(|tok| tok.end() == range.end());
176+
177+
let (Some(start), Some(end)) = (start, end) else {
178+
return &self.tokens;
179+
};
180+
181+
&self.tokens[start..=end]
182+
}
183+
169184
/// Consumes the token source, returning the collected tokens, comment ranges, and any errors
170185
/// encountered during lexing. The token collection includes both the trivia and non-trivia
171186
/// tokens.

0 commit comments

Comments
 (0)