Skip to content

Commit bf4a2b1

Browse files
authored
Fix zero padding for string format specs (#8407)
* Fix zero padding for string format specs Assisted-by: Codex:gpt-5 * Add string alignment format regression test Assisted-by: Codex:gpt-5
1 parent 1613741 commit bf4a2b1

4 files changed

Lines changed: 60 additions & 2 deletions

File tree

Lib/test/test_str.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1070,7 +1070,7 @@ def test_issue18183(self):
10701070
'\U00100000'.ljust(3, '\U00010000')
10711071
'\U00100000'.rjust(3, '\U00010000')
10721072

1073-
@unittest.expectedFailure # TODO: RUSTPYTHON; '{0:08s}'.format('result') misalign — '0' fill treated as numeric zero-pad for str type
1073+
@unittest.expectedFailure # TODO: RUSTPYTHON; '{0.}'.format() raises ValueError instead of IndexError
10741074
def test_format(self):
10751075
self.assertEqual(''.format(), '')
10761076
self.assertEqual('a'.format(), 'a')

crates/common/src/format.rs

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ pub struct FormatSpec {
227227
conversion: Option<FormatConversion>,
228228
fill: Option<CodePoint>,
229229
align: Option<FormatAlign>,
230+
align_specified: bool,
230231
sign: Option<FormatSign>,
231232
alternate_form: bool,
232233
width: Option<usize>,
@@ -346,6 +347,7 @@ impl FormatSpec {
346347
// get_integer in CPython
347348
let (conversion, text) = FormatConversion::parse(text);
348349
let (mut fill, mut align, text) = parse_fill_and_align(text);
350+
let align_specified = align.is_some();
349351
let (sign, text) = FormatSign::parse(text);
350352
let (alternate_form, text) = parse_alternate_form(text);
351353
let (zero, text) = parse_zero(text);
@@ -374,6 +376,7 @@ impl FormatSpec {
374376
conversion,
375377
fill,
376378
align,
379+
align_specified,
377380
sign,
378381
alternate_form,
379382
width,
@@ -1020,13 +1023,24 @@ impl FormatSpec {
10201023
self.validate_format(FormatType::String)?;
10211024
match self.format_type {
10221025
Some(FormatType::String) | None => {
1026+
if self.align == Some(FormatAlign::AfterSign) && self.align_specified {
1027+
return Err(FormatSpecError::StringAlignmentFlag);
1028+
}
10231029
// CPython parity: precision truncates BEFORE width pads.
10241030
// `'{:3.2s}'.format('abc')` -> 'ab ' (truncate to 'ab', pad to 3).
10251031
let truncated: String = match self.precision {
10261032
Some(p) => s.deref().chars().take(p).collect(),
10271033
None => s.deref().to_owned(),
10281034
};
1029-
Ok(self.format_sign_and_align(&truncated, "", FormatAlign::Left))
1035+
let spec = Self {
1036+
align: if self.align == Some(FormatAlign::AfterSign) {
1037+
Some(FormatAlign::Left)
1038+
} else {
1039+
self.align
1040+
},
1041+
..*self
1042+
};
1043+
Ok(spec.format_sign_and_align(&truncated, "", FormatAlign::Left))
10301044
}
10311045
_ => {
10321046
let ch = char::from(self.format_type.as_ref().unwrap());
@@ -1260,6 +1274,7 @@ pub enum FormatSpecError {
12601274
CodeNotInRange,
12611275
ZeroPadding,
12621276
AlignmentFlag,
1277+
StringAlignmentFlag,
12631278
NotImplemented(char, &'static str),
12641279
}
12651280

@@ -1609,6 +1624,7 @@ mod tests {
16091624
conversion: None,
16101625
fill: None,
16111626
align: None,
1627+
align_specified: false,
16121628
sign: None,
16131629
alternate_form: false,
16141630
width: Some(33),
@@ -1626,6 +1642,7 @@ mod tests {
16261642
conversion: None,
16271643
fill: Some('<'.into()),
16281644
align: Some(FormatAlign::Right),
1645+
align_specified: true,
16291646
sign: None,
16301647
alternate_form: false,
16311648
width: Some(33),
@@ -1643,6 +1660,7 @@ mod tests {
16431660
conversion: None,
16441661
fill: Some('<'.into()),
16451662
align: Some(FormatAlign::Right),
1663+
align_specified: true,
16461664
sign: Some(FormatSign::Minus),
16471665
alternate_form: true,
16481666
width: Some(23),
@@ -1690,6 +1708,35 @@ mod tests {
16901708
assert_eq!(format_bool("%", false), Ok("0.000000%".to_owned()));
16911709
}
16921710

1711+
#[test]
1712+
fn format_string_zero_padding_uses_left_alignment() {
1713+
let spec = FormatSpec::parse("08s").unwrap();
1714+
let value = "result".to_owned();
1715+
1716+
assert_eq!(spec.format_string(&value), Ok("result00".to_owned()));
1717+
}
1718+
1719+
#[test]
1720+
fn format_string_explicit_after_sign_alignment_is_invalid() {
1721+
let spec = FormatSpec::parse("=8s").unwrap();
1722+
let value = "result".to_owned();
1723+
1724+
assert_eq!(
1725+
spec.format_string(&value),
1726+
Err(FormatSpecError::StringAlignmentFlag)
1727+
);
1728+
}
1729+
1730+
#[test]
1731+
fn format_int_zero_padding_stays_after_sign() {
1732+
let spec = FormatSpec::parse("08").unwrap();
1733+
1734+
assert_eq!(
1735+
spec.format_int(&BigInt::from(-42)),
1736+
Ok("-0000042".to_owned())
1737+
);
1738+
}
1739+
16931740
#[test]
16941741
fn format_int() {
16951742
assert_eq!(

crates/vm/src/format.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ impl IntoPyException for FormatSpecError {
7777
Self::AlignmentFlag => {
7878
vm.new_value_error("'=' alignment flag is not allowed in complex format specifier")
7979
}
80+
Self::StringAlignmentFlag => {
81+
vm.new_value_error("'=' alignment not allowed in string format specifier")
82+
}
8083
Self::NotImplemented(c, s) => {
8184
let msg = format!("Format code '{c}' for object of type '{s}' not implemented yet");
8285
vm.new_value_error(msg)

extra_tests/snippets/builtin_format.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ def test_zero_padding():
2424

2525
test_zero_padding()
2626

27+
try:
28+
format("result", "=8s")
29+
except ValueError as error:
30+
if str(error) != "'=' alignment not allowed in string format specifier":
31+
raise AssertionError(f"unexpected error message: {error}") from error
32+
else:
33+
raise AssertionError("expected ValueError for '=8s' string format specifier")
34+
2735
assert "{:,}".format(100) == "100"
2836
assert "{:,}".format(1024) == "1,024"
2937
assert "{:_}".format(65536) == "65_536"

0 commit comments

Comments
 (0)