Skip to content

Commit 9c518bf

Browse files
authored
interpolated strings: align f-string and t-string diagnostics with CPython (#8601)
* builtins: name the other operand with tp_name in str and Template __add__ `str.__add__` and `Template.__add__` built their TypeError with `PyType::name()`, which drops the module, where CPython formats `tp_name`: >>> t"a" + "b" TypeError: can only concatenate Template (not 'str') to Template CPython 3.14 reports the qualified name and quotes the operand with double quotes, as `Objects/unicodeobject.c` and `Objects/templateobject.c` do: TypeError: can only concatenate string.templatelib.Template (not "str") to string.templatelib.Template `Template.__add__` also spelled its own name literally rather than taking it from `PyClassDef::TP_NAME`, so the two halves of the message could drift apart. Known gap: `slot_name()` still does not qualify a class defined in a module, so `"a" + collections.OrderedDict()` names `OrderedDict` where CPython names `collections.OrderedDict`. This affects both methods. Unblocks test_template_concatenation in test_tstring. Assisted-by: Claude Code:claude-opus-5 * interpolated strings: align f-string and t-string diagnostics with CPython RustPython re-derives CPython's syntax diagnostics by scanning the source rather than by translating ruff's parse errors, and the scanner shared by f-strings and t-strings misread several replacement field states: - A field that ran off the end of the literal (`f'{'`) was reported as an expression that failed to start, where CPython asks for the closing brace. - A field whose expression cannot start (`f'{;'`) and one whose expression runs into a stray character (`f'{a;'`) both fell through to the missing brace message instead of CPython's two distinct ones. - A conversion or format spec following `=` was never validated, and a format spec following a valid conversion was skipped entirely. - Format specs were scanned with the `{{` escape rule that only applies to literal text, so nested replacement fields inside them went unchecked. - Brackets opened inside a field were never matched, so `f'{a[4)}'` and `f'{3)+(4}'` fell back to a bare "invalid syntax". - Comments inside a field were not recognised, so a `#` that swallows the closing brace was reported as an unterminated literal, and a comment in a multi-line field hid the expression behind it. - Unterminated literals were named "string" whatever their prefix, and an interpolated literal left with a replacement field open reported the missing quote rather than the brace. A field is only "never closed" while the tokenizer is still reading its expression. Past the field's own `:` it is emitting literal text again, so `f'{a:>5` runs out of input as an ordinary unterminated literal while `f'{a` reports the brace. The field's own `:` is also not the one in a slice, a display or a lambda, and an unclosed bracket inside the expression is what CPython names rather than the field around it, so this walk tracks the whole delimiter stack. Two messages were worded from the wrong branch of CPython's tokenizer. The hint "perhaps you escaped the end quote?" is raised only where lexer.c handles a literal without an interpolation prefix; its `%c-string` branch has just the triple-quoted and plain forms, so pairing the hint with a prefix produced `unterminated f-string literal (...); perhaps you escaped the end quote?`, which CPython never emits. And a bracket mismatch inside a field dropped the ` on line %d` clause that lexer.c adds whenever `parenlinenostack[level]` differs from the current `lineno`; the general bracket scanner in this file already computed that suffix, so `f"""{a[\n4)}"""` now names line 1 as CPython does. The rules these now follow are the ones CPython spells out in Grammar/python.gram (`invalid_fstring_replacement_field` and its t-string twin) and in Parser/lexer/lexer.c, which likewise parameterises the literal's prefix rather than duplicating the messages. Only a field's expression is code. A format spec is text, so a `(` there opens nothing and a `#` selects the alternate form instead of starting a comment; inside the expression a `#` does start one. Scanning the whole field for either therefore reported diagnostics against valid literals, and because these scanners only run once the source has already failed to parse, that let a good literal take the blame for an error further down the file. Expression-level checks now stop at the field's top-level separator and skip comments, and the literal is walked field by field rather than byte by byte so that a `{` inside a comment no longer opens one. A pre-existing instance of the same bug in the line-continuation check is fixed along with them. Ruff reports a mixed literal concatenation only as a bytes/non-bytes mix, so `t"x" b"y"` arrived as a bytes error where CPython names the t-string. CPython's `invalid_string_tstring_concat` is an `invalid_` rule, reached only on the error pass, and `strings` tries `(fstring|string)+` ahead of it: that alternative consumes the concatenation's leading run of non-t-string literals, so a mix among those raises from `_PyPegen_concatenate_strings` on the first pass and sets `error_indicator`, which suppresses the t-string rule. The t-string message therefore wins for `t"x" b"y"` and the bytes message keeps precedence for `"a" b"b" t"c"`. Both are now settled here rather than left to whichever scanner runs next, which had been answering the second case with an unrelated "Is this intended to be part of the string?". A leading doubled `=` or `!` was read as a marker with an empty expression before it, so `f"{==a}"` reported `valid expression required before '='` where CPython has no expression to report at all. The same lookahead now guards that branch, and a doubled `=` or `!` counts as an expression that cannot start. A top-level `=` or `!` was read as a debug or conversion marker without checking whether it belonged to a longer operator, so `f"{a==b}"` and `f"{a!=b}"` were also read as fields ending early. CPython tokenises `==`, `!=`, `<=` and the rest as single tokens before it ever considers the debug marker, so the separator scan now skips a `=` that follows one of `= ! < > + - * / % & | ^ @ :` or precedes another `=`, and a `!` that precedes one. An expression cannot end on an operator that still wants an operand, and CPython points at that operator rather than at the brace. `f"{a==}"`, `f"{a and}"` and `f"{a.b.}"` fell through to a plain `invalid syntax`; they now carry the same message and column CPython gives, reusing the scan that already handled `;` and `$` for exactly this shape. `is not` and `not in` are single operators, so the first word is what gets pointed at, and `...` is consumed in whole triples so that `f"{....}"` blames the fourth dot rather than the first. The region has to be walked forwards: a comment's terminating newline is whitespace, so trimming backwards from the end would step into the comment body and read a triple-quoted field whose comment ends in `+` as an expression ending in `+`. That check runs only once the field is known to close, which is where a stray character and a dangling operator part ways. A stray character is a finished token, so the parser rejects it on the lookahead in `annotated_rhs !('='|'!'|':'|'}')` and `f"{a;"` gets the separator message even with no closing brace. A dangling operator instead makes the parser ask for one more token, and producing it runs into the literal's closing quote, where lexer.c answers from its `INSIDE_FSTRING(tok)` branch with `%c-string: expecting '}'` before any `invalid_` rule is reached. So `f"{a and"` is a missing brace while `f"{a and}"` names the operator. A leading `.` was read as a character that cannot start an expression, so the Ellipsis literal `f"{...}"`, the float `f"{.5}"` and its signed form `f"{-.5}"` were reported as broken whenever the file failed to parse somewhere else. `pegen` joins the cpython spelling dictionary, for the `_PyPegen_*` names these comments cite. Known gaps, none covered by either test file. Needing the longest valid expression prefix, which a character scanner cannot compute: a starred tuple (`f"{*a,}"`) and a dict-unpacking display are read as unable to start an expression, and `f"{a===b}"`, `f"{a b}"` and `f"{a,,}"` fall through to a bare `invalid syntax`. The caret still differs from CPython's on `expecting a valid expression after '{'`, `expecting '}'`, `expecting '}', or format specs` and `unterminated ... literal`, and on the messages CPython points at a whole token for, such as `f"{lambda x: x}"` and `f"{x! r}"`. A quote inside a format spec is still treated as a string delimiter. Two more are reachable only through a t-string concatenation that ruff does not report as a bytes mix, so `mixed_tstring_literal_error` never runs: three or more literals with no bytes literal among them (`t"a" t"b" "c"`, `"a" "b" t"c"`) answer with `invalid syntax. Is this intended to be part of the string?`, and so does a tokenizer-level nesting overflow where CPython has `too many nested f-strings or t-strings`. Two-literal mixes are correct because ruff's own error carries the wording. Two pre-existing bugs outside this change are worth naming, since it touches their neighbourhood. `unterminated triple-quoted ... literal` reports `detected at line 2` for a one-line source where CPython reports line 1, for every prefix including none, so it is in the shared line arithmetic rather than the interpolated path. And `str.__add__`'s message is unreachable for an operand that defines `__radd__`: `"a" + 1` falls through to the generic `unsupported operand type(s)` from the binop dispatch, where CPython has `can only concatenate str (not "int") to str`. Unblocks test_syntax_errors and test_literal_concatenation in test_tstring, and test_comments, test_conversions, test_invalid_syntax_error_message, test_mismatched_braces, test_mismatched_parens, test_parens_in_expressions and test_syntax_error_after_debug in test_fstring. Assisted-by: Claude Code:claude-opus-5 * diagnostics: give the source scanners a diagnostic type Every scanner in `cpython_parse_diagnostic_override` returned a bare `Option<(String, usize, usize)>` — 69 signatures and 114 construction sites of message, start offset, end offset — which the consumer then reassembled into a message and a range one call later. They return a `CpythonDiagnostic` now, and the two things that consume one, `NormalizedParseDiagnostic::other` and `CompileError::from_source_error`, take it whole. The type earns its name. These scanners run *after* ruff's parse has failed, so what they produce is not a parse error but a reconstruction of what CPython would have said about the same source; nothing here ever hands one back to ruff. Reusing `ruff_python_parser::ParseError` would have fit the shape — it is `{ ParseErrorType, TextRange }` — but every one of the 114 sites would have wrapped its message in the single `OtherError` variant of a ninety-variant enum, widened `other` and `from_source_error` to accept variants no caller constructs, and, being a foreign type, ruled out the constructor that now carries the `u32` cast and its justification. The `OtherError` wrapping happens once per consumer instead, at the boundary where this does cross into ruff's vocabulary. Two nameless tuples that carried more than a diagnostic get names, following `CallArgFrame` and `AssignmentContext` in this file: - `bracket_syntax_error` returned the message alongside a bare `bool` for whether the bracket was left open, which the caller needs apart from the message because ruff reports that case as an EOF error. It returns a `BracketError` now. - `unclosed_replacement_field_error` walked its delimiter stack as `Vec<(usize, u8, bool)>`, where the third element decided whether the field had reached its format spec — `.2 = true` and `Some((_, b'{', false))` at the use sites. `OpenDelimiter` names all three. The `source_error!` macro is untouched at the 34 call sites it already had and drops to five lines. Two copies of its body had been written out by hand (`invalid_number_literal_error`, `unterminated_string_error`) and fold into it, bringing it to 36. One thing does change beyond the packaging. `TextRange::new` asserts that its start does not exceed its end, and it asserts unconditionally. Building the range at the scanner rather than at the consumer therefore puts that assert in front of the hundred-odd scanners that reach `NormalizedParseDiagnostic::other`, which never built a range before — they handed their two offsets straight to `source_locations`. A scanner that emitted a reversed span used to produce a garbled location; now it aborts. Every site was read for this and each clamp is anchored to a bound at or after its start, and neither two hundred thousand generated sources nor eleven thousand mutations of stdlib files reached it, so this is a latent invariant made loud rather than a new failure — but it is not packaging, so it is written down here. Otherwise a pure refactor: no diagnostic changes message, column or line. Verified by building the parent commit and diffing its output against this one over a malformed-source corpus and over every `Lib/**/*.py` that parses clean (1728 files, each recompiled with a trailing `$` so a scanner that misreads valid code shows up as a moved line) — byte-identical, and the count of files where the reported line moves is 1149 on both. test_fstring, test_tstring, test_string_literals, test_syntax, test_exceptions, test_grammar and test_compile all pass. Addresses the review on #8601. Assisted-by: Claude Code:claude-opus-5 * diagnostics: name the operator-character set behind the debug marker `is_replacement_field_marker` carried its list of operator characters inline, a sixteen-line `matches!` wrapped around three lines of logic, and it looked very much like `is_dangling_operator_byte` a few functions down — enough that a reviewer asked whether they were the same set. They are not, and neither contains the other. Written in the same order the difference is three characters: precedes_equals_in_one_operator ! % & * + - / : < = > @ ^ | is_dangling_operator_byte ! % & * + - . / < = > @ ^ | ~ The two are derived from different things. One asks which bytes pair with a following `=` to make a single token, so that the `=` is part of that operator rather than the start of a debug specifier — read off Python's token table, and `.=` and `~=` are not on it, while `:=` is. The other asks which bytes make up an operator that still wants an operand, which is why `~a` and `a.b.` put `.` and `~` there and why the separator `:` stays out. So the list moves next to the one it resembles, under a name, both sorted the same way, and each says what the other has that it does not. Neither is defined in terms of the other: the thirteen characters they share are two answers that happen to coincide, not a set with a meaning of its own, and a common list would tie each to the other's reasons to change. No behavior change: the extracted list is character for character what was inline. Verified against a build of the parent over a malformed-source corpus and every `Lib/**/*.py` that parses clean — byte-identical — and with test_fstring, test_tstring, test_string_literals, test_syntax, test_exceptions, test_grammar and test_compile. Addresses the review on #8601. Assisted-by: Claude Code:claude-opus-5 * diagnostics: give the unterminated-literal scan one exit A single-quoted literal that meets a newline and one that runs off the end of the source are the same failure, and CPython's lexer says so in one condition: if (c == EOF || (quote_size == 1 && c == '\n')) { Everything after that — the check for a replacement field left open, and the choice of message — is written once there. This scan had grown a second exit for the newline case with its own copy of both, so the guard added for interpolated literals had to be added twice and the message assembled twice. The newline case now breaks out of the scan and falls into the exit that was already handling end of source. `line` cannot have moved from `start_line` on that path, and `quote_size` is 1, so the surviving exit computes the same `detected_line` and the same `triple` the deleted one did. No behavior change: verified against a build of the parent over a malformed-source corpus and every `Lib/**/*.py` that parses clean — byte-identical — and with test_fstring, test_tstring, test_string_literals, test_syntax, test_exceptions, test_grammar and test_compile. Assisted-by: Claude Code:claude-opus-5 * diagnostics: classify an unterminated triple-quoted t-string as incomplete input `analyze_compile_error` pairs the f-string and t-string error variants everywhere it names a message, but the match that decides whether a failure is a `SyntaxError` or an `IncompleteInputError` listed only the f-string one. So a `t'''` typed at the prompt would not have asked for another line where an `f'''` would. Would not have, rather than does not: the arm is unreachable today. The compiler's `unterminated_string_error` scanner claims these first and hands back an `OtherError`, so `f'''` and a bare `'''` get a `SyntaxError` at the prompt too, on `main` as much as here — which is why `test_codeop.test_incomplete` carries an `expectedFailure`. Whoever untangles that precedence should not then find that f-strings work and t-strings silently do not. CPython does not separate the two either: `lexer.c` emits one message parameterised with `%c` for the prefix and sets `E_EOFS` the same way for both, and `_is_end_of_source` (`Parser/pegen.c`) looks only at that code, never at which kind of literal produced it. No behavior change, and none is testable while the arm cannot be reached. Assisted-by: Claude Code:claude-opus-5
1 parent 8c616c2 commit 9c518bf

7 files changed

Lines changed: 1330 additions & 324 deletions

File tree

.cspell.dict/cpython.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ parg
189189
pathconfig
190190
patma
191191
peepholer
192+
pegen
192193
phcount
193194
platstdlib
194195
ploc

Lib/test/test_fstring.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -606,7 +606,6 @@ def test_unterminated_string(self):
606606
r"""f'{("x}'""",
607607
])
608608

609-
@unittest.expectedFailure # TODO: RUSTPYTHON
610609
@unittest.skipIf(support.is_wasi, "exhausts limited stack on WASI")
611610
def test_mismatched_parens(self):
612611
self.assertAllRaise(SyntaxError, r"closing parenthesis '\}' "
@@ -737,7 +736,6 @@ def test_compile_time_concat(self):
737736
['''f'{3' f"}"''', # can't concat to get a valid f-string
738737
])
739738

740-
@unittest.expectedFailure # TODO: RUSTPYTHON
741739
def test_comments(self):
742740
# These aren't comments, since they're in strings.
743741
d = {'#': 'hash'}
@@ -931,7 +929,6 @@ def test_missing_expression(self):
931929
"\xa0",
932930
])
933931

934-
@unittest.expectedFailure # TODO: RUSTPYTHON
935932
def test_parens_in_expressions(self):
936933
self.assertEqual(f'{3,}', '(3,)')
937934

@@ -1342,7 +1339,6 @@ def test_equal_equal(self):
13421339

13431340
self.assertEqual(f'{0==1}', 'False')
13441341

1345-
@unittest.expectedFailure # TODO: RUSTPYTHON
13461342
def test_conversions(self):
13471343
self.assertEqual(f'{3.14:10.10}', ' 3.14')
13481344
self.assertEqual(f'{1.25!s:10.10}', '1.25 ')
@@ -1413,7 +1409,6 @@ def test_del(self):
14131409
"del '' f''",
14141410
])
14151411

1416-
@unittest.expectedFailure # TODO: RUSTPYTHON
14171412
def test_mismatched_braces(self):
14181413
self.assertAllRaise(SyntaxError, "f-string: single '}' is not allowed",
14191414
["f'{{}'",
@@ -1701,7 +1696,6 @@ def test_walrus(self):
17011696
self.assertEqual(f'{(x:=10)}', '10')
17021697
self.assertEqual(x, 10)
17031698

1704-
@unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: "f-string: expecting '=', or '!', or ':', or '}'" does not match "invalid syntax (?, line 1)"
17051699
def test_invalid_syntax_error_message(self):
17061700
with self.assertRaisesRegex(SyntaxError,
17071701
"f-string: expecting '=', or '!', or ':', or '}'"):
@@ -1755,7 +1749,7 @@ def test_not_closing_quotes(self):
17551749
except SyntaxError as e:
17561750
self.assertEqual(e.text, 'z = f"""')
17571751
self.assertEqual(e.lineno, 3)
1758-
@unittest.expectedFailure # TODO: RUSTPYTHON
1752+
17591753
def test_syntax_error_after_debug(self):
17601754
self.assertAllRaise(SyntaxError, "f-string: expecting a valid expression after '{'",
17611755
[

Lib/test/test_tstring.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,6 @@ def test_raw_tstrings(self):
150150
t = tr"{path}\Documents"
151151
self.assertTStringEqual(t, ("", r"\Documents"), [(path, "path")])
152152

153-
@unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: "can only concatenate string.templatelib.Template \(not "str"\) to string.templatelib.Template" does not match "can only concatenate Template (not 'str') to Template"
154153
def test_template_concatenation(self):
155154
# Test template + template
156155
t1 = t"Hello, "
@@ -197,7 +196,6 @@ def test_nested_templates(self):
197196
self.assertEqual(t_interp.conversion, None)
198197
self.assertEqual(t_interp.format_spec, "")
199198

200-
@unittest.expectedFailure # TODO: RUSTPYTHON multiple instances of AssertionError
201199
def test_syntax_errors(self):
202200
for case, err in (
203201
("t'", "unterminated t-string literal"),
@@ -232,7 +230,6 @@ def test_runtime_errors(self):
232230
with self.assertRaises(NameError):
233231
eval("t'Hello, {name}'")
234232

235-
@unittest.expectedFailure # TODO: RUSTPYTHON
236233
def test_literal_concatenation(self):
237234
# Test concatenation of t-string literals
238235
t = t"Hello, " t"world"

0 commit comments

Comments
 (0)