Files
Roshan Ramani b701e5cbe9 Report which format string mistake was made (#8659)
* 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

* fix(common): separate a stray `{` from an unclosed field

`parse_literal` already reports `UnescapedStartBracketInLiteral` for a brace
that was not doubled, but `from_str` discards it with `or_else` and retries as a
field, so `"{".format()` surfaced `UnmatchedBracket` and read as
"expected '}' before end of string". CPython separates the two by whether
anything follows the brace:

    "{"   "a{"    Single '{' encountered in format string
    "{s"  "a{b"   expected '}' before end of string
    "{0"          expected '}' before end of string

`parse_spec` now returns the literal error when the text is the brace alone,
which is also what makes `UnescapedStartBracketInLiteral` reachable — the
previous commit could map it but not trigger it.

`format_parse_lone_start_bracket` pins both directions at the parser level, and
the snippet covers the four messages under CPython 3.14.0 as well. The existing
`format_parse_fail` assertion for `"{s"` is unchanged.

Assisted-by: Claude Code:claude-opus-5
2026-09-08 17:52:57 +09:00

381 lines
14 KiB
Python

from testutils import assert_raises
assert format(5, "b") == "101"
assert_raises(TypeError, format, 2, 3, _msg="format called with number")
assert format({}) == "{}"
assert_raises(TypeError, format, {}, "b", _msg="format_spec not empty for dict")
class BadFormat:
def __format__(self, spec):
return 42
assert_raises(TypeError, format, BadFormat())
def test_zero_padding():
i = 1
assert f"{i:04d}" == "0001"
test_zero_padding()
try:
format("result", "=8s")
except ValueError as error:
if str(error) != "'=' alignment not allowed in string format specifier":
raise AssertionError(f"unexpected error message: {error}") from error
else:
raise AssertionError("expected ValueError for '=8s' string format specifier")
# regression: a sign, a space or an alternate form used to be accepted and dropped.
for spec, flag in [
("+", "Sign"),
("-", "Sign"),
("+5", "Sign"),
(" ", "Space"),
(" 5", "Space"),
("#", "Alternate form (#)"),
("#5", "Alternate form (#)"),
("+.2", "Sign"),
]:
try:
format("result", spec)
except ValueError as error:
expected = f"{flag} not allowed in string format specifier"
if str(error) != expected:
raise AssertionError(
f"{spec!r}: unexpected error message: {error}"
) from error
else:
raise AssertionError(
f"expected ValueError for {spec!r} string format specifier"
)
# regression: unknown conversion specifiers used to be silently ignored instead of raising.
# The ValueError case itself is covered by test_str, but here we're testing the error message.
try:
"{0!x}".format(3)
except ValueError as error:
if str(error) != "Unknown conversion specifier x":
raise AssertionError(f"unexpected error message: {error}") from error
else:
raise AssertionError("expected ValueError for unknown conversion specifier '!x'")
# 'b' is a valid conversion specifier for %-style bytes formatting, but not for str.format().
try:
"{0!b}".format(3)
except ValueError as error:
if str(error) != "Unknown conversion specifier b":
raise AssertionError(f"unexpected error message: {error}") from error
else:
raise AssertionError("expected ValueError for unknown conversion specifier '!b'")
assert "{:,}".format(100) == "100"
assert "{:,}".format(1024) == "1,024"
assert "{:_}".format(65536) == "65_536"
assert "{:_}".format(4294967296) == "4_294_967_296"
assert f"{100:_}" == "100"
assert f"{1024:_}" == "1_024"
assert f"{65536:,}" == "65,536"
assert f"{4294967296:,}" == "4,294,967,296"
assert "F" == "{0:{base}}".format(15, base="X")
assert f"{255:#X}" == "0XFF"
assert f"{65:c}" == "A"
assert f"{0x1F5A5:c}" == "🖥"
assert_raises(
ValueError,
"{:+c}".format,
1,
_msg="Sign not allowed with integer format specifier 'c'",
)
assert_raises(
ValueError,
"{:#c}".format,
1,
_msg="Alternate form (#) not allowed with integer format specifier 'c'",
)
assert f"{256:#010x}" == "0x00000100"
assert f"{256:0=#10x}" == "0x00000100"
assert f"{256:0>#10x}" == "000000x100"
assert f"{256:0^#10x}" == "000x100000"
assert f"{256:0<#10x}" == "0x10000000"
assert f"{512:+#010x}" == "+0x0000200"
assert f"{512:0=+#10x}" == "+0x0000200"
assert f"{512:0>+#10x}" == "0000+0x200"
assert f"{512:0^+#10x}" == "00+0x20000"
assert f"{512:0<+#10x}" == "+0x2000000"
assert f"{123:,}" == "123"
assert f"{1234:,}" == "1,234"
assert f"{12345:,}" == "12,345"
assert f"{123456:,}" == "123,456"
assert f"{123:03_}" == "123"
assert f"{123:04_}" == "0_123"
assert f"{123:05_}" == "0_123"
assert f"{123:06_}" == "00_123"
assert f"{123:07_}" == "000_123"
assert f"{255:#010_x}" == "0x000_00ff"
assert f"{255:+#010_x}" == "+0x00_00ff"
assert f"{123.4567:,}" == "123.4567"
assert f"{1234.567:,}" == "1,234.567"
assert f"{12345.67:,}" == "12,345.67"
assert f"{123456.7:,}" == "123,456.7"
assert f"{123.456:07,}" == "123.456"
assert f"{123.456:08,}" == "0,123.456"
assert f"{123.456:09,}" == "0,123.456"
assert f"{123.456:010,}" == "00,123.456"
assert f"{123.456:011,}" == "000,123.456"
assert f"{123.456:+011,}" == "+00,123.456"
assert f"{1234:.3g}" == "1.23e+03"
assert f"{1234567:.6G}" == "1.23457E+06"
assert f"{1234:10}" == " 1234"
assert f"{1234:10,}" == " 1,234"
assert f"{1234:010,}" == "00,001,234"
assert f"{'🐍':4}" == "🐍 "
assert_raises(
ValueError, "{:,o}".format, 1, _msg="ValueError: Cannot specify ',' with 'o'."
)
assert_raises(
ValueError, "{:_n}".format, 1, _msg="ValueError: Cannot specify '_' with 'n'."
)
assert_raises(
ValueError, "{:,o}".format, 1.0, _msg="ValueError: Cannot specify ',' with 'o'."
)
assert_raises(
ValueError, "{:_n}".format, 1.0, _msg="ValueError: Cannot specify '_' with 'n'."
)
assert_raises(
ValueError, "{:,}".format, "abc", _msg="ValueError: Cannot specify ',' with 's'."
)
assert_raises(
ValueError, "{:,x}".format, "abc", _msg="ValueError: Cannot specify ',' with 'x'."
)
assert_raises(
OverflowError,
"{:c}".format,
0x110000,
_msg="OverflowError: %c arg not in range(0x110000)",
)
assert f"{3:f}" == "3.000000"
assert f"{3.1415:.0f}" == "3"
assert f"{3.1415:.1f}" == "3.1"
assert f"{3.1415:.2f}" == "3.14"
assert f"{3.1415:.3f}" == "3.142"
assert f"{3.1415:.4f}" == "3.1415"
assert f"{3.1415:#.0f}" == "3."
assert f"{3.1415:#.1f}" == "3.1"
assert f"{3.1415:#.2f}" == "3.14"
assert f"{3.1415:#.3f}" == "3.142"
assert f"{3.1415:#.4f}" == "3.1415"
assert f"{3:g}" == "3"
assert f"{3.1415:.0g}" == "3"
assert f"{3.1415:.1g}" == "3"
assert f"{3.1415:.2g}" == "3.1"
assert f"{3.1415:.3g}" == "3.14"
assert f"{3.1415:.4g}" == "3.142"
assert f"{0.000012:g}" == "1.2e-05"
assert f"{0.000012:G}" == "1.2E-05"
assert f"{3:#g}" == "3.00000"
assert f"{3.1415:#.0g}" == "3."
assert f"{3.1415:#.1g}" == "3."
assert f"{3.1415:#.2g}" == "3.1"
assert f"{3.1415:#.3g}" == "3.14"
assert f"{3.1415:#.4g}" == "3.142"
assert f"{0.000012:#g}" == "1.20000e-05"
assert f"{0.000012:#G}" == "1.20000E-05"
assert f"{3.1415:.0e}" == "3e+00"
assert f"{3.1415:.1e}" == "3.1e+00"
assert f"{3.1415:.2e}" == "3.14e+00"
assert f"{3.1415:.3e}" == "3.142e+00"
assert f"{3.1415:.4e}" == "3.1415e+00"
assert f"{3.1415:.5e}" == "3.14150e+00"
assert f"{3.1415:.5E}" == "3.14150E+00"
assert f"{3.1415:#.0e}" == "3.e+00"
assert f"{3.1415:#.1e}" == "3.1e+00"
assert f"{3.1415:#.2e}" == "3.14e+00"
assert f"{3.1415:#.3e}" == "3.142e+00"
assert f"{3.1415:#.4e}" == "3.1415e+00"
assert f"{3.1415:#.5e}" == "3.14150e+00"
assert f"{3.1415:#.5E}" == "3.14150E+00"
assert f"{3.1415:.0%}" == "314%"
assert f"{3.1415:.1%}" == "314.2%"
assert f"{3.1415:.2%}" == "314.15%"
assert f"{3.1415:.3%}" == "314.150%"
assert f"{3.1415:#.0%}" == "314.%"
assert f"{3.1415:#.1%}" == "314.2%"
assert f"{3.1415:#.2%}" == "314.15%"
assert f"{3.1415:#.3%}" == "314.150%"
assert f"{3.1415:.0}" == "3e+00"
assert f"{3.1415:.1}" == "3e+00"
assert f"{3.1415:.2}" == "3.1"
assert f"{3.1415:.3}" == "3.14"
assert f"{3.1415:.4}" == "3.142"
assert f"{3.1415:#.0}" == "3.e+00"
assert f"{3.1415:#.1}" == "3.e+00"
assert f"{3.1415:#.2}" == "3.1"
assert f"{3.1415:#.3}" == "3.14"
assert f"{3.1415:#.4}" == "3.142"
assert f"{1234.5:10}" == " 1234.5"
assert f"{1234.5:10,}" == " 1,234.5"
assert f"{1234.5:010,}" == "0,001,234.5"
assert f"{12.34 + 5.6j}" == "(12.34+5.6j)"
assert f"{12.34 - 5.6j: }" == "( 12.34-5.6j)"
assert f"{12.34 + 5.6j:20}" == " (12.34+5.6j)"
assert f"{12.34 + 5.6j:<20}" == "(12.34+5.6j) "
assert f"{-12.34 + 5.6j:^20}" == " (-12.34+5.6j) "
assert f"{12.34 + 5.6j:^+20}" == " (+12.34+5.6j) "
assert f"{12.34 + 5.6j:_^+20}" == "___(+12.34+5.6j)____"
assert f"{-12.34 + 5.6j:f}" == "-12.340000+5.600000j"
assert f"{12.34 + 5.6j:.3f}" == "12.340+5.600j"
assert f"{12.34 + 5.6j:<30.8f}" == "12.34000000+5.60000000j "
assert f"{12.34 + 5.6j:g}" == "12.34+5.6j"
assert f"{12.34 + 5.6j:e}" == "1.234000e+01+5.600000e+00j"
assert f"{12.34 + 5.6j:E}" == "1.234000E+01+5.600000E+00j"
assert f"{12.34 + 5.6j:^30E}" == " 1.234000E+01+5.600000E+00j "
assert f"{12345.6 + 7890.1j:,}" == "(12,345.6+7,890.1j)"
assert f"{12345.6 + 7890.1j:_.3f}" == "12_345.600+7_890.100j"
assert f"{12345.6 + 7890.1j:>+30,f}" == " +12,345.600000+7,890.100000j"
assert f"{123456:,g}" == "123,456"
assert f"{123456:,G}" == "123,456"
assert f"{123456:,e}" == "1.234560e+05"
assert f"{123456:,E}" == "1.234560E+05"
assert f"{123456:,%}" == "12,345,600.000000%"
# test issue 4558
x = 123456789012345678901234567890
for i in range(0, 30):
format(x, ",")
x = x // 10
# Large float precision must not abort the interpreter.
# Previously these paths hit unguarded `format!("{:.*e}", ...)` in
# crates/literal/src/float.rs and `crates/common/src/format.rs` (the `%`
# branch), which panic past Rust's fmt precision limit and killed the
# process instead of raising a Python exception. Internally the limit is
# u16::MAX; output is zero-padded past that boundary to match CPython
# byte-identically.
# Three precision points per format type — below the cap (uncapped
# path), exactly at the cap (boundary), and one past the cap (the
# unhappy case, where internal clamping plus zero-padding has to
# reconstruct CPython's output). All must byte-match CPython.
# f-format pads with trailing zeros up to the requested precision.
assert "{:.65534f}".format(1.5) == "1." + "5" + "0" * 65533 # below cap
assert "{:.65535f}".format(1.5) == "1." + "5" + "0" * 65534 # at cap
assert "{:.65536f}".format(1.5) == "1." + "5" + "0" * 65535 # past cap → padding
# e-format emits a fixed mantissa width + 'e+00'.
assert "{:.65534e}".format(1.5) == "1." + "5" + "0" * 65533 + "e+00" # below
assert "{:.65535e}".format(1.5) == "1." + "5" + "0" * 65534 + "e+00" # at cap
assert (
"{:.65536e}".format(1.5) == "1." + "5" + "0" * 65535 + "e+00"
) # past cap → padding
# %-format multiplies by 100 then applies f-format.
assert "{:.65534%}".format(1.5) == "150." + "0" * 65534 + "%" # below
assert "{:.65535%}".format(1.5) == "150." + "0" * 65535 + "%" # at cap
assert "{:.65536%}".format(1.5) == "150." + "0" * 65536 + "%" # past cap → padding
# g-format strips trailing zeros, so the short form is the natural
# representation regardless of precision.
for p in (65534, 65535, 65536, 1_000_000):
assert ("{:." + str(p) + "g}").format(1.5) == "1.5"
# Far past the cap — verifies the pad path handles arbitrary precision,
# not just one-off values near the boundary.
assert len("{:.1000000f}".format(1.5)) == 1_000_002 # "1." + 1M zeros
assert len("{:.1000000e}".format(1.5)) == 1_000_006 # + "e+00"
assert len("{:.1000000%}".format(1.5)) == 1_000_005 # "150." + 1M zeros + "%"
# Percent overflow: finite input whose *100 is +inf produces "inf%"
# rather than crashing. CPython does the same.
assert "{:.100000%}".format(1.7976931348623157e308) == "inf%"
# Shallow cases unchanged.
assert f"{1.5:.5}" == "1.5"
assert "{:.3f}".format(1.5) == "1.500"
assert "{:.2%}".format(0.25) == "25.00%"
assert "{:.4e}".format(1234.5) == "1.2345e+03"
assert "{:.3g}".format(1234.5) == "1.23e+03"
assert f"{float('nan'):.10f}" == "nan"
assert f"{float('inf'):.10f}" == "inf"
# bool has no __format__ of its own, so the int rules apply. Only the bare
# spec spells the value out.
assert format(True, "") == "True"
assert format(False, "") == "False"
assert f"{True}" == "True"
assert f"{True:}" == "True"
assert format(True, "5") == " 1"
assert format(True, "<5") == "1 "
assert format(False, ">5") == " 0"
assert format(True, "^5") == " 1 "
assert format(True, "=5") == " 1"
assert format(True, "05") == "00001"
assert format(False, "05") == "00000"
assert format(True, "+") == "+1"
assert format(False, " ") == " 0"
assert format(True, ",") == "1"
assert format(True, "<") == "1"
assert "{:>6}|{:^6}".format(True, False) == " 1| 0 "
# The presentation types were already right, and stay right.
assert format(True, "d") == "1"
assert format(True, "#b") == "0b1"
assert format(False, "x") == "0"
assert format(True, "c") == "\x01"
assert format(True, "e") == "1.000000e+00"
assert format(True, "%") == "100.000000%"
# Precision belongs to no integer spec, bool included.
assert_raises(ValueError, format, True, ".2")
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"
# A brace with nothing after it is a stray brace; one holding an unclosed
# field is not.
assert message("{") == "Single '{' encountered in format string"
assert message("a{") == "Single '{' encountered in format string"
assert message("{s") == "expected '}' before end of string"
assert message("a{b") == "expected '}' before end of string"
test_format_parse_error_messages()