Skip to content

interpolated strings: align f-string and t-string diagnostics with CPython - #8601

Open
name-of-okja wants to merge 2 commits into
RustPython:mainfrom
name-of-okja:tstring-concat-error-message
Open

interpolated strings: align f-string and t-string diagnostics with CPython#8601
name-of-okja wants to merge 2 commits into
RustPython:mainfrom
name-of-okja:tstring-concat-error-message

Conversation

@name-of-okja

@name-of-okja name-of-okja commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

One of checkbox below must be checked.

  • I did not use AI to write the code of this patch.
  • This PR follows our AI policy

Summary

RustPython synthesizes CPython's syntax-error messages by re-scanning the source in
cpython_parse_diagnostic_override once ruff's parse has already failed. The scanner
shared by f-strings and t-strings misread several replacement-field states, which is
what #8495 reports.

Fixing that surfaced a second and larger problem. Because these scanners run only
after a parse failure, a scanner that misreads a valid literal makes that literal
take the blame for an error elsewhere in the file. Several of the field checks treated
the whole field as code, so a format spec containing ( or a comment containing +
was enough to move the reported error onto a perfectly good line.

Both are addressed against the rules CPython spells out in Grammar/python.gram,
Parser/lexer/lexer.c and Parser/pegen*. All CPython links below are permalinks to
197fdd7.

1. Replacement field states

invalid_fstring_replacement_field
gives four distinct answers, and the order of the alternatives is the priority. The
t-string twin
is the same rule with the prefix swapped. "the field has no content" and "the expression
failed to start" were collapsed into one branch here; they are now separate, and
lexer.c:1181-1192
is explicit that a field still open at end of input becomes expecting '}'.

input before after (= CPython 3.14)
f'{' expecting a valid expression after '{' expecting '}'
f'{1=}{;' expecting '}' expecting a valid expression after '{'
f'{1=}{1;' expecting '}' expecting '=', or '!', or ':', or '}'
t'{x=!}' invalid conversion character missing conversion character
t'{x!s:' expecting `}` expecting '}', or format specs

A conversion or format spec following = was never validated, and a format spec
following a valid conversion was skipped entirely; both now run the same checks they
would have run directly after the expression, as
invalid_fstring_conversion_character
being referenced after '='? implies.

2. Only the expression is code

fstring_format_spec
is a separate grammar rule from annotated_rhs. So inside a format spec a ( opens
nothing and a # selects the alternate form, while inside the expression a # starts a
comment that runs to end of line. Scanning the whole field for either produced
diagnostics against valid literals:

x = f"{1:#x}"     # was reported as: '{' was never closed
x = f"""{a # +
}"""              # was reported as an expression ending on `+`

The 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 a {
inside a comment no longer opens one. A pre-existing instance of the same bug in the
line-continuation check is fixed with them.

The spec also has no {{ escape at that level, so nested fields inside a spec were
never checked at all. They are now, resuming past each nested field once checked —
recursing per { without resuming was O(2^depth) (a 110-byte source took 6.1s;
there is a regression test at depth 200).

3. Brackets and comments inside a field

lexer.c:1342
attributes a closer with nothing open to the literal (so it carries the prefix) while a
kind mismatch keeps the generic bracket wording (so it does not). Brackets opened in a
field were not matched at all before.

input before after
f'{a[4)}' invalid syntax closing parenthesis ')' does not match opening parenthesis '['
f'{3)+(4}' f-string: expecting '}' f-string: unmatched ')'
f'{1#}' unterminated string literal (detected at line 1) '{' was never closed

4. The literal's kind belongs in the message

lexer.c:1500-1514
parameterises the prefix with %c rather than duplicating the messages, and this
scanner shares the same structure — which is why the f-string cases move together with
the t-string ones here. Separating them would need an artificial branch.

input before after
t' unterminated string literal (detected at line 1) unterminated t-string literal (detected at line 1)
t''' unterminated triple-quoted string literal … unterminated triple-quoted t-string literal …

An interpolated literal that runs out of input with a field still open reports the brace
instead, per pegen_errors.c:18
— but only while the tokenizer is reading that field's expression. Past the field's own
: it is emitting FSTRING_MIDDLE again, so running out of input there is an ordinary
unterminated literal. And the field's own : is not the one in a slice, a display or a
lambda, nor is the field the innermost unclosed delimiter when a bracket is open inside
its expression. So this walk carries the whole delimiter stack:

input CPython before
t'{, f'{a, f'{a!r, f'{a= '{' was never closed unterminated string literal …
f'{ {1:2}, f'{d[1:2], f'{(lambda x: x) '{' was never closed unterminated f-string literal …
f'{a:, f'{a:>5, f'{a!r:, f'{a}{b: unterminated f-string literal … '{' was never closed
f'''{a:>5 unterminated triple-quoted f-string literal … '{' was never closed
f'{a[ '[' was never closed '{' was never closed
f'{(a '(' was never closed '{' was never closed

The last four rows are cases an earlier revision of this branch got wrong; main answers
every row above with unterminated string literal.

5. Mixed literal concatenation

Ruff reports a mixed concatenation only as a bytes/non-bytes mix, so t"x" b"y" arrived
as a bytes error where CPython names the t-string.

invalid_string_tstring_concat
is an invalid_ rule, reached only on the error pass
(pegen.c:964-974),
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
suppresses
the t-string rule. So the precedence splits:

input CPython 3.14 before
t"x" b"y" cannot mix t-string literals with string or bytes literals cannot mix bytes and nonbytes literals
b"x" t"y" cannot mix t-string literals with … cannot mix bytes and nonbytes literals
"a" b"b" t"c" cannot mix bytes and nonbytes literals invalid syntax. Is this intended to be part of the string?
f"x" b"y" cannot mix bytes and nonbytes literals unchanged

Both messages are now decided in one place. The third row has to be emitted rather
than deferred to ruff's own error, because otherwise invalid_string_expression_error
further down the chain claims the concatenation with an unrelated message.

All 14 cases from test_tstring.test_literal_concatenation and both from
test_fstring.test_compile_time_concat_errors match.

6. Type names in the operand message

CPython uses tp_name for
str,
i.e. the module-qualified name; PyType::name() strips the module, slot_name() does
not. Builtin types have no dot in TP_NAME, so this only changes types that declare a
module.

input before after
t"a" + "b" can only concatenate Template (not 'str') to Template can only concatenate string.templatelib.Template (not "str") to string.templatelib.Template
"a" + t"b" … (not "Template") to str … (not "string.templatelib.Template") to str

7. Operators the scanner had no tokenizer for

CPython consumes ==, !=, <= and the rest as single tokens at
lexer.c:1280-1298,
before the = ever reaches the
debug-marker check.
The scanner has no such stage, so f"{a==b}" — valid code — was read as a field ending
early. Token boundaries are now inferred from the surrounding characters; the character
set is exactly the operators that end in =.

An expression also cannot end on an operator that still wants an operand, and CPython
points at that operator rather than at the brace. is not / not in are single
operators so the first word is pointed at, and ... is consumed in whole triples.

input before after
f"{a==b}" (valid) f-string: expecting '!', or ':', or '}' no error
f'{==a}' valid expression required before '=' expecting a valid expression after '{'
f'{a==}' invalid syntax (col 7) expecting '=', or '!', or ':', or '}' (col 5)
f'{a is not}' invalid syntax (col 9) same message, col 6
f"{...}", f"{.5}", f"{-.5}" (valid) reported as broken no error

This region has to be walked forwards: a comment's terminating newline is whitespace, so
trimming backwards from the end steps into the comment body.

Verification

Ran on x86-64 Linux (WSL2, Ubuntu). t-string reference behavior is taken from the
vendored Lib/test/*.py and the pinned CPython source — note that a locally installed
python3.14 may be a beta that predates invalid_string_tstring_concat entirely.

check result
Lib/test/test_tstring.py 12/12, all 3 expectedFailure markers removed
Lib/test/test_fstring.py 90 OK, 7 markers removed (6 remain)
CPython message comparison, 27 diagnostics 27/27 match
mixed-concatenation precedence, 18 cases 18/18 match
unclosed field / bracket boundary, 29 cases 29/29 messages match
dangling-operator caret columns, 7 cases 7/7 match CPython
valid literals not blamed for a later error 24 shadow + 67 comment-tail cases, 0 false positives
valid expressions, 45 cases 1 false positive — starred tuple, pre-existing
whole stdlib parses (Lib/, 1728 files) 4 failures, all intentional bad-syntax fixtures
cargo test -p rustpython-compiler 24 passed
cargo clippy -p rustpython-compiler --all-targets no new warnings
adjacent suites test_syntax, test_compile, test_exceptions, test_grammar, test_string, test_ast, test_tokenize, test_codeop, test_traceback, test_str, test_bytes, test_types all pass

Known gaps

All pre-existing, none covered by either test file, all noted in the commit message:

  • a starred tuple (f"{*a,}") and a dict-unpacking display are still read as unable to
    start an expression;
  • f"{a===b}", f"{a b}" and f"{a,,}" need the longest valid expression prefix, which
    a character scanner cannot compute — f"{a b}" is CPython's "forgot a comma" rule;
  • the caret column still differs from CPython's, while the messages match. Over a
    49-case sweep of interpolated-string errors the messages agree 49/49 and the
    offset/end_offset pair differs on 42. It splits into four independent anchors:
    unterminated … literal (CPython points at column 1, the prefix letter; we point at the
    quote), expecting '}' (CPython points past the last token, we point at the {),
    expecting a valid expression after '{' (CPython spans the offending token, we point at
    the {), and the operator messages (CPython spans the whole operator, our end_offset
    is one past the start). assertRaisesRegex only inspects the message, which is why
    neither test file covers this. Left out to keep this reviewable;
  • Template.__add__ names the other operand with tp_name where CPython uses
    %T
    (identical for static types, divergent for heap types) — RustPython has no %T
    equivalent outside the C-API shim, so this is left for a follow-up;
  • a quote inside a format spec is still treated as a string delimiter.

AI assistance disclosure

Per the AI policy: this patch was written with Claude Code (claude-opus-5), also
recorded as an Assisted-by trailer on the commit.

Extent: Claude wrote the scanner changes, the added tests and the commit message, working
from the CPython sources linked above. I set the scope, reviewed the diff, and ran every
check in the Verification table locally on the platform above.

Summary by CodeRabbit

  • Bug Fixes
    • Improved syntax-error messages for interpolated strings, including clearer diagnostics for unterminated literals, invalid replacement fields, conversions, and mixed string types.
    • Added more precise handling for nested expressions, brackets, comments, operators, and unexpected characters.
    • Updated string and template concatenation errors to report accurate type names.
    • Improved compiler error output by removing redundant prefixes and providing clearer t-string diagnostics.

Copilot AI lite review requested due to automatic review settings August 28, 2026 06:37
@github-actions github-actions Bot added the z-ca-2026 Tag to track Contribution Academy 2026 label Aug 28, 2026
@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

📦 Library Dependencies

The following Lib/ modules were modified. Here are their dependencies:

[ ] test: cpython/Lib/test/test_str.py (TODO: 5)
[ ] test: cpython/Lib/test/test_fstring.py (TODO: 6)
[x] test: cpython/Lib/test/test_string_literals.py (TODO: 4)

dependencies:

dependent tests: (no tests depend on str)

[x] test: cpython/Lib/test/test_tstring.py

dependencies:

dependent tests: (no tests depend on tstring)

Legend:

  • [+] path exists in CPython
  • [x] up-to-date, [ ] outdated

@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8896662f-c88f-42ac-aea4-a6e165df276e

📥 Commits

Reviewing files that changed from the base of the PR and between 9640b75 and 183639c.

📒 Files selected for processing (1)
  • .cspell.dict/cpython.txt

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.


📝 Walkthrough

Walkthrough

The compiler now produces CPython-aligned diagnostics for f-strings and t-strings, including nested replacement fields, literal mixing, comments, brackets, conversions, and unterminated fields. VM errors now use qualified type names for string and template concatenation.

Changes

Interpolated-string diagnostics

Layer / File(s) Summary
Literal-level diagnostic handling
crates/compiler/src/lib.rs
The compiler detects mixed t-string literals, unclosed replacement fields, and the specific unterminated literal kind.
Replacement-field scanning and validation
crates/compiler/src/lib.rs
Replacement-field parsing now handles nested format specifications, comments, brackets, markers, conversions, stray characters, dangling operators, and bounded recursion. Tests cover CPython-compatible diagnostics, linear nested scanning, and later syntax errors.
VM diagnostic and concatenation messages
crates/vm/src/vm/vm_new.rs, crates/vm/src/builtins/str.rs, crates/vm/src/builtins/template.rs, .cspell.dict/cpython.txt
VM diagnostics recognize t-string parse errors, and concatenation errors use qualified runtime type names. The spell-check dictionary includes pegen.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to 18363

The PR updates interpolated-string diagnostics to match CPython behavior and includes focused verification; no actionable merge-blocking risk remains beyond normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant SourceParser
  participant CompilerDiagnostics
  participant ReplacementFieldScanner
  participant VMErrorAnalyzer
  SourceParser->>CompilerDiagnostics: report f-string or t-string error
  CompilerDiagnostics->>ReplacementFieldScanner: validate replacement fields
  ReplacementFieldScanner-->>CompilerDiagnostics: return diagnostic
  CompilerDiagnostics-->>VMErrorAnalyzer: provide compile error
  VMErrorAnalyzer-->>SourceParser: format final error message
Loading

Suggested reviewers: youknowone

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 51.35% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 37 functions across 4 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: aligning interpolated-string diagnostics with CPython.
Linked Issues check ✅ Passed The changes address the linked issue objectives [#8495], including fully qualified template type names, mixed t-string and string/bytes diagnostics, and related f-string and t-string syntax-error hand…
Out of Scope Changes check ✅ Passed The changes remain within scope [#8495]. Compiler diagnostics, operand type-name formatting, template concatenation handling, and the supporting spelling-dictionary update all support the stated CPyth…
Full details: Linked Issues check

Explanation

The changes address the linked issue objectives [#8495], including fully qualified template type names, mixed t-string and string/bytes diagnostics, and related f-string and t-string syntax-error handling.

Full details: Out of Scope Changes check

Explanation

The changes remain within scope [#8495]. Compiler diagnostics, operand type-name formatting, template concatenation handling, and the supporting spelling-dictionary update all support the stated CPython-alignment objectives.

Full details: Docstring Coverage

Explanation

Docstring coverage is 51.35% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 37 functions across 4 files. (1 skipped: 1 unsupported.)

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@name-of-okja
name-of-okja force-pushed the tstring-concat-error-message branch from 61cd516 to cb40067 Compare August 28, 2026 06:38

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves RustPython’s CPython-compatibility for interpolated string (f-string and PEP 750 t-string) diagnostics by refining the post-parse-failure rescan logic and aligning several error messages and caret targets with CPython behavior. It also updates related stdlib tests by removing expectedFailure markers that should now pass.

Changes:

  • Refactors the compiler’s interpolated-string diagnostic rescan to better model CPython replacement-field parsing (separators, comments, operators, nested fields) and avoid misattributing later syntax errors to valid literals.
  • Aligns SyntaxError/TypeError wording with CPython for t-strings (unterminated literal messages; mixed-literal concatenation precedence; template/str concatenation operand type names).
  • Removes @unittest.expectedFailure markers in Lib/test/test_tstring.py and Lib/test/test_fstring.py that should now be passing.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
Lib/test/test_tstring.py Removes expectedFailure markers for t-string behaviors now aligned with CPython.
Lib/test/test_fstring.py Removes expectedFailure markers for f-string syntax/diagnostic cases now expected to pass.
crates/vm/src/vm/vm_new.rs Adjusts SyntaxError message mapping for t-string unterminated literals and interpolated-string error formatting.
crates/vm/src/builtins/template.rs Updates Template concatenation TypeError text to use module-qualified type names (slot_name).
crates/vm/src/builtins/str.rs Updates str concatenation TypeError operand naming to use slot_name.
crates/compiler/src/lib.rs Extends/adjusts CPython-style diagnostic override logic for interpolated strings, including new tests and performance fix for deeply nested format specs.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +4353 to +4369
// The expression started fine but ran into a character that cannot continue it. CPython
// points at that character and lists the separators it wanted instead.
if let Some(stray) = replacement_expression_stray_character(bytes, expr_start, expr_end) {
return Some((
format!("{prefix}: expecting '=', or '!', or ':', or '}}'"),
stray,
stray + 1,
));
}

if let Some(operator) = dangling_operator(bytes, expr_start, expr_end) {
return Some((
format!("{prefix}: expecting '=', or '!', or ':', or '}}'"),
operator,
operator + 1,
));
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — the divergence is real, and it was still present at the time of the review. Verified against CPython 3.14:

f'{a and'     CPython: f-string: expecting '}'   ← this branch: expecting '=', or '!', or ':', or '}'
f'{a+'        f'{a=='   f'{a.b.'   f'{a~'   f'{a is not'   t'{a and'   (same)

The suggested fix — guarding both checks on separator.is_some() — would have regressed a second set, though. CPython gives the separator message for a stray character even with no closing brace:

f'{a;'   f'{a$'   f'{a?'   f'{a`'   →  f-string: expecting '=', or '!', or ':', or '}'

The line runs between the two, and it is drawn in the tokenizer rather than the grammar. A stray character is a finished token, so the parser rejects it on the lookahead in annotated_rhs !('='|'!'|':'|'}') without asking for another token. A dangling operator makes it ask for one more, and producing that token runs into the literal's closing quote, where Parser/lexer/lexer.c:1181 answers first:

if (INSIDE_FSTRING(tok)) {
    /* ...this must be a missing '}' token so raise the proper error */
    if (the_current_tok->quote == quote && the_current_tok->quote_size == quote_size) {
        return MAKE_TOKEN(_PyTokenizer_syntaxerror(tok,
            "%c-string: expecting '}'", TOK_GET_STRING_PREFIX(tok)));
    }
}

So the fix is to move only the dangling_operator check below the separator guard and leave replacement_expression_stray_character where it is — no new condition, just the reorder. Done in eb42e6e, with a regression test covering both sides of the boundary (11 cases) and the mechanism recorded in the commit message. All 16 inputs above now match CPython.

Comment on lines +5096 to +5116
interpolated_string_prefix(bytes, quote_start)?;
let mut index = content_start;
let mut open = None;
while index < content_end {
match bytes[index] {
b'{' if bytes.get(index + 1) == Some(&b'{') => index += 2,
b'}' if bytes.get(index + 1) == Some(&b'}') => index += 2,
b'{' => {
open = Some(index);
index += 1;
}
b'}' => {
open = None;
index += 1;
}
_ => index += 1,
}
}
let open = open?;
Some(("'{' was never closed".to_owned(), open, open + 1))
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct diagnosis, and this had already been rewritten by the time the review landed — the single open slot is now a full delimiter stack, for exactly the reason given here:

// One entry per unclosed delimiter: its position, its opening character, and — for a brace —
// whether that field has reached its format spec.
let mut open: Vec<(usize, u8, bool)> = Vec::new();

The bool mirrors the tokenizer's in_format_spec: only the field's own : sets it, so a : in a slice, a dict display or a lambda no longer ends the expression, and running out of input inside a format spec is an ordinary unterminated literal rather than an unclosed brace. Brackets and quotes are tracked too, since CPython names the innermost unclosed delimiter.

Verified against CPython 3.14 — the display cases this comment names among them:

f'{ {1:2}          '{' was never closed
f'{ {1,2}          '{' was never closed
f'{d[1:2]          '{' was never closed
f'{(lambda x: x)   '{' was never closed
f'{a[              '[' was never closed
f'{(a              '(' was never closed
f'{a:>5            unterminated f-string literal (detected at line 1)

18 inputs in that group, all matching, pinned by a unit test.

@name-of-okja
name-of-okja force-pushed the tstring-concat-error-message branch 2 times, most recently from 9640b75 to 183639c Compare August 28, 2026 08:09
@name-of-okja
name-of-okja marked this pull request as draft August 28, 2026 08:11
…dd__

`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
@name-of-okja
name-of-okja force-pushed the tstring-concat-error-message branch from 183639c to 062c3ad Compare August 28, 2026 13:49
…ython

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
@name-of-okja
name-of-okja force-pushed the tstring-concat-error-message branch from 062c3ad to eb42e6e Compare August 29, 2026 02:10
@name-of-okja

Copy link
Copy Markdown
Contributor Author

Both review comments are addressed; replies with the details are on the threads.

  • unclosed_replacement_field_error single open slot — already rewritten as a full delimiter stack before the review landed. The entry carries the opening position, the opening character, and whether that field reached its format spec, mirroring the tokenizer's in_format_spec. Balanced braces in a dict/set display no longer clear it, and CPython's innermost-delimiter naming is reproduced.

  • stray_character / dangling_operator running before the separator is known — real, and fixed in eb42e6e. Only the dangling_operator check moved below the separator guard: CPython answers a stray character from the grammar even with no closing brace (f'{a;'), while a dangling operator makes the parser ask for one more token, which runs into the closing quote and is answered by Parser/lexer/lexer.c:1181 with %c-string: expecting '}'. Guarding both would have regressed the first set.

The branch is also split in two now: the runtime tp_name change is its own commit, verified to build and pass test_tstring/test_fstring on its own.

@copilot review

@name-of-okja
name-of-okja marked this pull request as ready for review August 29, 2026 03:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

z-ca-2026 Tag to track Contribution Academy 2026

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PEP 750: align remaining t-string diagnostics with CPython

2 participants