Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
fix(vm): report which format string mistake was made
`ToPyException for FormatParseError` mapped two variants and sent the rest to
"Unexpected error parsing format string", so most format-string mistakes gave
no hint about the mistake. Against CPython 3.14.0:

| expression | before | CPython |
| --- | --- | --- |
| `"a}b".format()` | Unexpected error… | Single `}` encountered in format string |
| `"{0.}".format(1)` | Unexpected error… | Empty attribute in format string |
| `"{0[]}".format([1])` | Unexpected error… | Empty attribute in format string |
| `"{0[1}".format([1,2])` | Unexpected error… | expected `}` before end of string |
| `"{0[0]x}".format([1])` | Unexpected error… | Only `.` or `[` may follow `]` in format field specifier |
| `"{:{{}}".format(1)` | Unexpected error… | unmatched `{` in format spec |

The match is now exhaustive, so a new variant cannot silently inherit the
generic message.

Two cases are deliberately left generic, with the reason in a comment beside
each. `UnknownConversion` is raised both for a multi-character conversion
(`{0!xy}`, where CPython says "expected ':' after conversion specifier") and
for an empty one (`{0!}`, "unmatched '{' in format spec"), so one message
cannot serve both without the parser saying which it hit. And a trailing lone
`{` reaches `UnmatchedBracket` rather than
`UnescapedStartBracketInLiteral`, so `"{".format()` still differs from CPython;
that needs a parser change, not a message.

Expectations in the new snippet are CPython 3.14.0's output and pass under both
interpreters. Reverting only format.rs fails the snippet's first assertion.

Assisted-by: Claude Code:claude-opus-5
  • Loading branch information
rawsun007 committed Sep 6, 2026
commit 21857cc8ff30f5d5806f4c7e5a7fe1384dc37738
28 changes: 26 additions & 2 deletions crates/vm/src/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,36 @@ impl IntoPyException for FormatSpecError {

impl ToPyException for FormatParseError {
fn to_pyexception(&self, vm: &VirtualMachine) -> PyBaseExceptionRef {
// Matched exhaustively on purpose: a catch-all sent most of these to
// "Unexpected error parsing format string", which tells the reader nothing
// about the format string they mistyped.
match self {
Self::UnmatchedBracket => vm.new_value_error("expected '}' before end of string"),
Self::UnmatchedBracket | Self::MissingRightBracket => {
vm.new_value_error("expected '}' before end of string")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Self::MissingStartBracket => {
vm.new_value_error("Single '}' encountered in format string")
}
// The variant means a bracket in literal text that was not doubled. A
// trailing lone `{` reaches UnmatchedBracket instead, so I could not
// construct an input that lands here; the message is what CPython uses
// for that condition.
Self::UnescapedStartBracketInLiteral => {
vm.new_value_error("Single '{' encountered in format string")
}
Self::InvalidFormatSpecifier => vm.new_value_error("unmatched '{' in format spec"),
// Reached both for a conversion that is not one character (`{0!xy}`,
// where CPython says "expected ':' after conversion specifier") and for
// an empty one (`{0!}`, "unmatched '{' in format spec"). Telling those
// apart needs the parser to say which, so the generic message stays.
Self::UnknownConversion => vm.new_value_error("Unexpected error parsing format string"),
Self::EmptyAttribute => vm.new_value_error("Empty attribute in format string"),
Self::InvalidCharacterAfterRightBracket => {
vm.new_value_error("Only '.' or '[' may follow ']' in format field specifier")
}
Self::TooManyDecimalDigits => {
vm.new_value_error("Too many decimal digits in format string")
}
_ => vm.new_value_error("Unexpected error parsing format string"),
}
}
}
Expand Down
35 changes: 35 additions & 0 deletions extra_tests/snippets/builtin_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,3 +336,38 @@ def test_zero_padding():
assert_raises(ValueError, format, True, "5.2")
assert_raises(ValueError, format, True, "z")
assert_raises(ValueError, format, True, "s")


def test_format_parse_error_messages():
# Most FormatParseError variants used to fall into a catch-all that said
# "Unexpected error parsing format string", which does not say what is wrong
# with the format string. Messages are CPython 3.14.0's.
def message(fmt, *args):
try:
fmt.format(*args)
except ValueError as err:
return str(err)
raise AssertionError("ValueError was not raised for " + repr(fmt))

assert message("a}b") == "Single '}' encountered in format string"
assert message("{{a}") == "Single '}' encountered in format string"

assert message("{0.}", 1) == "Empty attribute in format string"
assert message("{0[]}", [1]) == "Empty attribute in format string"

assert message("{0[1}", [1, 2]) == "expected '}' before end of string"
assert message("{0", 1) == "expected '}' before end of string"

assert (
message("{0[0]x}", [1])
== "Only '.' or '[' may follow ']' in format field specifier"
)
assert (
message("{0[0]]}", [1])
== "Only '.' or '[' may follow ']' in format field specifier"
)

assert message("{:{{}}", 1) == "unmatched '{' in format spec"


test_format_parse_error_messages()
Loading