Skip to content

Commit 962c20f

Browse files
Fix complex repr to use scientific notation for large integer-valued components
repr of a complex number whose real or imaginary part is an integer-valued float with |x| >= 1e16 emitted the full decimal expansion instead of scientific notation, diverging from CPython: Before (RustPython): repr(1e100 + 1e100j) (10000000000000000000000000000000000000000000000000000000000 0000000000000000000000000000000000000000000+1000000000000000 000000000000000000000000000000000000000000000000000000000000 00000000000000000000000000000000000000j) After / CPython: (1e+100+1e+100j) Root cause in crates/literal/src/complex.rs::to_string — it bifurcated each component by .fract() == 0.0: if im.fract() == 0.0 { im.to_string() } // Rust's default Display else { float::to_string(im) } // scientific for large/small Rust's Display never uses scientific notation, so any integer-valued f64 (including 1e16, 1e17, 1e100 which are exactly representable as integers) routed through the wrong branch and produced the full decimal expansion. Non-integer magnitudes reached float::to_string and rendered correctly. The fix is to use one helper per component that implements CPython's actual PyOS_double_to_string(format='r') rule: scientific notation when |x| < 1e-4 or |x| >= 1e16, otherwise Rust's default Display (which drops the trailing '.0' for integer-valued floats — matching CPython's (1+2j) convention rather than (1.0+2.0j)). The threshold matches float::to_string; the only behavioral difference is that complex components render 1.0 as "1" rather than "1.0". Verified: * 29 CPython reference cases (normal / boundary / extremes / special / signed-zero) — all byte-identical after fix. * 18 additional edge cases (subnormal 5e-324, f64::MAX, MIN_POSITIVE, DBL_EPSILON, threshold-straddling values) — all byte-identical. * Lib/test/test_complex.py::test_repr_str / test_negative_zero_repr_str / test_repr_roundtrip — all pass. * cargo run -- -m test test_complex — 37 passed. * cargo run -- -m test test_float test_long — 101 passed. * ast.unparse() round-trip of source containing complex literals (e.g. 1e100 + 1e-100j, 1e17 + 1j) produces CPython-identical output. * extra_tests/snippets/builtin_complex.py — 20+ new regression cases.
1 parent fdb49d8 commit 962c20f

2 files changed

Lines changed: 62 additions & 9 deletions

File tree

crates/literal/src/complex.rs

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,39 @@ use crate::float;
22
use alloc::borrow::ToOwned;
33
use alloc::string::{String, ToString};
44

5+
/// Format a single complex component (real or imag) for `repr`.
6+
/// Uses scientific notation when `|value| < 1e-4` or `|value| >= 1e16`
7+
/// (matching CPython's `PyOS_double_to_string(format='r')`), otherwise
8+
/// Rust's default `Display`, which drops the trailing `.0` for
9+
/// integer-valued floats.
10+
///
11+
/// This differs from `float::to_string` only in that integer values in
12+
/// the normal range render as `"1"` rather than `"1.0"` — complex repr
13+
/// formats `1+2j` as `"(1+2j)"`, not `"(1.0+2.0j)"`.
14+
fn component_to_string(value: f64) -> String {
15+
let lit = alloc::format!("{value:e}");
16+
if let Some(position) = lit.find('e') {
17+
let significand = &lit[..position];
18+
let exponent = lit[position + 1..].parse::<i32>().unwrap();
19+
if exponent < 16 && exponent > -5 {
20+
// Normal magnitude — Rust's default Display emits "1" for 1.0,
21+
// "1.5" for 1.5, "1000000000000000" for 1e15, etc.
22+
value.to_string()
23+
} else {
24+
alloc::format!("{significand}e{exponent:+#03}")
25+
}
26+
} else {
27+
// nan / inf / -inf — `format!("{x:e}")` produces e.g. "NaN" with no
28+
// exponent marker; lowercase to match Python.
29+
let mut s = value.to_string();
30+
s.make_ascii_lowercase();
31+
s
32+
}
33+
}
34+
535
/// Convert a complex number to a string.
636
pub fn to_string(re: f64, im: f64) -> String {
7-
// integer => drop ., fractional => float_ops
8-
let mut im_part = if im.fract() == 0.0 {
9-
im.to_string()
10-
} else {
11-
float::to_string(im)
12-
};
37+
let mut im_part = component_to_string(im);
1338
im_part.push('j');
1439

1540
// positive empty => return im_part, integer => drop ., fractional => float_ops
@@ -19,10 +44,8 @@ pub fn to_string(re: f64, im: f64) -> String {
1944
} else {
2045
"-0".to_owned()
2146
}
22-
} else if re.fract() == 0.0 {
23-
re.to_string()
2447
} else {
25-
float::to_string(re)
48+
component_to_string(re)
2649
};
2750
let mut result =
2851
String::with_capacity(re_part.len() + im_part.len() + 2 + im.is_sign_positive() as usize);

extra_tests/snippets/builtin_complex.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,3 +236,33 @@ class complex_subclass(complex):
236236
z = complex_subclass(3 + 4j)
237237
assert z.__complex__() == 3 + 4j
238238
assert type(z.__complex__()) == complex
239+
240+
241+
# repr must use scientific notation for |value| >= 1e16 or < 1e-4, matching
242+
# CPython. Previously integer-valued large magnitudes (e.g. 1e16, 1e100) hit
243+
# a `fract() == 0.0` branch in rustpython_literal::complex::to_string that
244+
# used Rust's default Display — which emits the full decimal expansion
245+
# (`10000...000`) instead of `1e+16`.
246+
assert repr(1e16 + 1j) == "(1e+16+1j)"
247+
assert repr(1e17 + 1j) == "(1e+17+1j)"
248+
assert repr(1e100 + 1e100j) == "(1e+100+1e+100j)"
249+
assert repr(-1e100 - 1e100j) == "(-1e+100-1e+100j)"
250+
assert repr(1e-100 + 1e100j) == "(1e-100+1e+100j)"
251+
assert repr(1 + 1e100j) == "(1+1e+100j)"
252+
assert repr(1e100 + 1j) == "(1e+100+1j)"
253+
254+
# Values at the threshold boundaries must stay in non-scientific form.
255+
assert repr(1e15 + 1j) == "(1000000000000000+1j)"
256+
assert repr(1e-4 + 1j) == "(0.0001+1j)"
257+
assert repr(1e-5 + 1j) == "(1e-05+1j)"
258+
259+
# Integer-valued components render without trailing ".0".
260+
assert repr(1 + 2j) == "(1+2j)"
261+
assert repr(1.0 + 2.0j) == "(1+2j)"
262+
263+
# Special values still round-trip correctly.
264+
assert repr(float("nan") + 1j) == "(nan+1j)"
265+
assert repr(float("inf") + 1j) == "(inf+1j)"
266+
assert repr(float("-inf") + 1j) == "(-inf+1j)"
267+
assert repr(complex(1, float("nan"))) == "(1+nanj)"
268+
assert repr(complex(1, float("inf"))) == "(1+infj)"

0 commit comments

Comments
 (0)