Skip to content

Commit c2e4f1b

Browse files
Jacob Zhongclaude
andcommitted
Implement __format__ (Python format mini-language) for all types
- UBig/IBig: delegate to Python int.__format__ (arbitrary precision, all integer presentation types: b/o/d/x/X/c/n, sign/width/align/fill/zero/grouping). - FBig/DBig: new format.rs renders in decimal (FBig converts via to_decimal) with e/E/f/g/n/% types, precision, sign, width, align, fill, zero-pad, grouping, and normalizes dashu's bare exponent to CPython's `e+00` form. Arbitrary precision is preserved: f"{FBig(2).with_precision(200).exp():.20e}" -> 7.38905609893065022723e+00. - RBig: empty spec -> "num/den"; float specs render via a high-precision conversion. - CBig: applies the spec to both parts -> "(re+imj)". Added tests/test_format.py. 34/34 tests pass, 0 clippy warnings, fmt clean. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 9ce999f commit c2e4f1b

8 files changed

Lines changed: 395 additions & 14 deletions

File tree

python/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121
`UBig(n) += 5`, `RBig.from_parts(1, 3)`. The module-level `math` functions, `powf`,
2222
`atan2`, `gcd`/`gcd_ext`/`lcm`, `is_multiple_of`/`remove`, in-place ops, `powi`,
2323
`from_parts`, `ilog`, and `simplest_from_float` all take native int/float.
24+
- `__format__` now honors the Python format mini-language for all types: scientific
25+
(`e`/`E`), fixed (`f`), general (`g`), integer (`b`/`o`/`d`/`x`/`X`/`c`), with
26+
sign/width/align/fill/zero-pad/grouping and precision. Float formatting preserves
27+
the value's arbitrary precision (e.g. `f"{FBig(2).with_precision(200).exp():.20e}"`).
2428
- Broadened constructors: `FBig`/`DBig`/`RBig`/`CBig` now accept any Python number
2529
(int/float/`Decimal`/`Fraction`) in addition to strings.
2630
- A module-level `math` API (`sin`/`cos`/…/`exp`/`ln`/`sqrt`/`gcd`/`lcm`/…) and a

python/src/complex.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,17 @@ impl CPy {
118118
fn __str__(&self) -> String {
119119
format!("{}", self.0)
120120
}
121-
fn __format__(&self, _format_spec: &str) -> String {
122-
format!("{}", self.0)
121+
fn __format__(&self, format_spec: &str) -> PyResult<String> {
122+
if format_spec.is_empty() {
123+
return Ok(format!("{}", self.0));
124+
}
125+
let (re, im) = self.0.clone().into_parts();
126+
let re_s = crate::format::format_dbig(&re.to_decimal().value(), format_spec)?;
127+
let mut im_s = crate::format::format_dbig(&im.to_decimal().value(), format_spec)?;
128+
if !(im_s.starts_with('-') || im_s.starts_with('+')) {
129+
im_s = format!("+{im_s}");
130+
}
131+
Ok(format!("({re_s}{im_s}j)"))
123132
}
124133
fn __hash__(&self) -> u64 {
125134
// mirror Python's complex hash convention loosely: combine real/imag float hashes

python/src/float.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,8 @@ impl FPy {
111111
fn __str__(&self) -> String {
112112
format!("{}", self.0)
113113
}
114-
fn __format__(&self, _format_spec: &str) -> String {
115-
format!("{}", self.0)
114+
fn __format__(&self, format_spec: &str) -> PyResult<String> {
115+
crate::format::format_dbig(&self.0.to_decimal().value(), format_spec)
116116
}
117117
fn __hash__(&self) -> u64 {
118118
let mut hasher = DefaultHasher::new();
@@ -395,8 +395,8 @@ impl DPy {
395395
fn __str__(&self) -> String {
396396
format!("{}", self.0)
397397
}
398-
fn __format__(&self, _format_spec: &str) -> String {
399-
format!("{}", self.0)
398+
fn __format__(&self, format_spec: &str) -> PyResult<String> {
399+
crate::format::format_dbig(&self.0, format_spec)
400400
}
401401
fn __hash__(&self) -> u64 {
402402
let mut hasher = DefaultHasher::new();

python/src/format.rs

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
//! Python format mini-language (`__format__`) support.
2+
//!
3+
//! Integers (`UBig`/`IBig`) delegate to Python's own `int.__format__` (Python ints are
4+
//! arbitrary precision, so there is no loss). Floats (`FBig`/`DBig`) are rendered in decimal
5+
//! (an `FBig` is first converted to base 10) using dashu's precision-aware formatting, then
6+
//! the Python spec's layout (sign / width / align / fill / zero-pad / grouping) and the
7+
//! scientific exponent are normalized to CPython conventions.
8+
9+
use dashu_float::DBig;
10+
use pyo3::PyResult;
11+
use pyo3::exceptions::PyValueError;
12+
13+
/// A parsed Python format spec: `[[fill]align][sign][#][0][width][grouping][.precision][type]`.
14+
#[allow(dead_code)]
15+
pub struct Spec {
16+
pub fill: char,
17+
pub align: Option<char>, // '<' '>' '=' '^'
18+
pub sign: char, // '+' '-' ' '
19+
pub alt: bool, // '#'
20+
pub zero: bool, // '0' (zero-pad)
21+
pub width: Option<usize>,
22+
pub group: Option<char>, // ',' or '_'
23+
pub prec: Option<usize>,
24+
pub ty: char, // '\0' = none
25+
}
26+
27+
const FLOAT_TYPES: &str = "eEfFgGn%";
28+
29+
pub fn parse(spec: &str) -> PyResult<Spec> {
30+
let chars: Vec<char> = spec.chars().collect();
31+
let mut i = 0;
32+
let n = chars.len();
33+
34+
// [[fill]align]
35+
let mut fill = ' ';
36+
let mut align = None;
37+
if n >= 2 && "<>=^".contains(chars[1]) {
38+
fill = chars[0];
39+
align = Some(chars[1]);
40+
i = 2;
41+
} else if n >= 1 && "<>=^".contains(chars[0]) {
42+
align = Some(chars[0]);
43+
i = 1;
44+
}
45+
46+
// [sign]
47+
let mut sign = '-';
48+
if i < n && "+- ".contains(chars[i]) {
49+
sign = chars[i];
50+
i += 1;
51+
}
52+
53+
// [#]
54+
let mut alt = false;
55+
if i < n && chars[i] == '#' {
56+
alt = true;
57+
i += 1;
58+
}
59+
60+
// [0]
61+
let mut zero = false;
62+
if i < n && chars[i] == '0' {
63+
zero = true;
64+
i += 1;
65+
}
66+
67+
// [width]
68+
let mut width = None;
69+
let start = i;
70+
while i < n && chars[i].is_ascii_digit() {
71+
i += 1;
72+
}
73+
if i > start {
74+
width = Some(chars[start..i].iter().collect::<String>().parse().unwrap());
75+
}
76+
77+
// [grouping]
78+
let mut group = None;
79+
if i < n && (chars[i] == ',' || chars[i] == '_') {
80+
group = Some(chars[i]);
81+
i += 1;
82+
}
83+
84+
// [.precision]
85+
let mut prec = None;
86+
if i < n && chars[i] == '.' {
87+
i += 1;
88+
let start = i;
89+
while i < n && chars[i].is_ascii_digit() {
90+
i += 1;
91+
}
92+
if i == start {
93+
return Err(PyValueError::new_err("missing precision in format spec"));
94+
}
95+
prec = Some(chars[start..i].iter().collect::<String>().parse().unwrap());
96+
}
97+
98+
// [type]
99+
let ty = if i < n {
100+
let t = chars[i];
101+
if i + 1 != n {
102+
return Err(PyValueError::new_err(format!("invalid format specifier '{spec}'")));
103+
}
104+
t
105+
} else {
106+
'\0'
107+
};
108+
109+
Ok(Spec {
110+
fill,
111+
align,
112+
sign,
113+
alt,
114+
zero,
115+
width,
116+
group,
117+
prec,
118+
ty,
119+
})
120+
}
121+
122+
impl Spec {
123+
fn is_float_type(&self) -> bool {
124+
self.ty == '\0' || FLOAT_TYPES.contains(self.ty)
125+
}
126+
}
127+
128+
/// Render a `DBig` (base-10 float) according to a parsed Python spec, including layout.
129+
pub fn format_dbig(d: &DBig, spec_str: &str) -> PyResult<String> {
130+
let s = parse(spec_str)?;
131+
if !s.is_float_type() {
132+
let ty_str = if s.ty == '\0' {
133+
String::new()
134+
} else {
135+
s.ty.to_string()
136+
};
137+
return Err(PyValueError::new_err(format!(
138+
"unknown format code '{ty_str}' for object of type 'float'"
139+
)));
140+
}
141+
142+
// Produce the unsigned (no sign policy) numeric body in decimal.
143+
let (negative, body) = render_body(d, &s)?;
144+
let signed = apply_sign(body, negative, s.sign);
145+
let grouped = apply_grouping(signed, s.group);
146+
Ok(apply_layout(grouped, &s))
147+
}
148+
149+
/// Render the magnitude as a decimal string (without sign), per the spec's type.
150+
fn render_body(d: &DBig, s: &Spec) -> PyResult<(bool, String)> {
151+
// infinite values: let dashu render, then strip the sign
152+
let (neg, raw) = match s.ty {
153+
'e' | 'E' => {
154+
let p = s.prec.unwrap_or(6);
155+
let mut raw = format!("{:.*e}", p, d);
156+
raw = normalize_sci(raw, s.ty == 'E');
157+
strip_sign(&raw)
158+
}
159+
'f' | 'F' => {
160+
let p = s.prec.unwrap_or(6);
161+
strip_sign(&format!("{:.*}", p, d))
162+
}
163+
'%' => {
164+
let p = s.prec.unwrap_or(6);
165+
// ×100, fixed, then a trailing '%'
166+
let scaled = d.clone() * DBig::from(100u8);
167+
let (neg, mut body) = strip_sign(&format!("{:.*}", p, &scaled));
168+
body.push('%');
169+
(neg, body)
170+
}
171+
'g' | 'G' | 'n' | '\0' => {
172+
// general / default: full plain decimal (dashu Display), with optional precision
173+
// limiting significant digits via a re-round.
174+
if let Some(p) = s.prec {
175+
let rounded = d.clone().with_precision(p.max(1)).value();
176+
strip_sign(&format!("{}", rounded))
177+
} else {
178+
strip_sign(&format!("{}", d))
179+
}
180+
}
181+
_ => unreachable!(),
182+
};
183+
Ok((neg, raw))
184+
}
185+
186+
/// Split a leading '-' from a dashu-rendered string; return (is_negative, magnitude_str).
187+
fn strip_sign(s: &str) -> (bool, String) {
188+
if let Some(rest) = s.strip_prefix('-') {
189+
(true, rest.to_string())
190+
} else if let Some(rest) = s.strip_prefix('+') {
191+
(false, rest.to_string())
192+
} else {
193+
(false, s.to_string())
194+
}
195+
}
196+
197+
/// Reattach the sign according to the Python sign option ('+', '-', ' ').
198+
fn apply_sign(body: String, negative: bool, sign: char) -> String {
199+
if negative {
200+
format!("-{body}")
201+
} else {
202+
match sign {
203+
'+' => format!("+{body}"),
204+
' ' => format!(" {body}"),
205+
_ => body,
206+
}
207+
}
208+
}
209+
210+
/// Insert a grouping separator every 3 digits in the integer part.
211+
fn apply_grouping(mut body: String, group: Option<char>) -> String {
212+
let sep = match group {
213+
Some(c) => c,
214+
None => return body,
215+
};
216+
// locate the integer-part digits: from start (or after a leading sign) up to '.'/'e'/'E'/'%'
217+
let bytes: Vec<char> = body.chars().collect();
218+
let start = if bytes
219+
.first()
220+
.map(|c| *c == '-' || *c == '+')
221+
.unwrap_or(false)
222+
{
223+
1
224+
} else {
225+
0
226+
};
227+
let end = bytes
228+
.iter()
229+
.enumerate()
230+
.skip(start)
231+
.find(|(_, c)| **c == '.' || **c == 'e' || **c == 'E' || **c == '%')
232+
.map(|(i, _)| i)
233+
.unwrap_or(bytes.len());
234+
if end <= start {
235+
return body;
236+
}
237+
let int_digits: Vec<char> = bytes[start..end].to_vec();
238+
let m = int_digits.len();
239+
let mut grouped = String::new();
240+
for (k, c) in int_digits.iter().enumerate() {
241+
if k > 0 && (m - k) % 3 == 0 {
242+
grouped.push(sep);
243+
}
244+
grouped.push(*c);
245+
}
246+
// reassemble
247+
let prefix: String = bytes[..start].iter().collect();
248+
let suffix: String = bytes[end..].iter().collect();
249+
body = format!("{prefix}{grouped}{suffix}");
250+
body
251+
}
252+
253+
/// Apply width / align / fill / zero-pad.
254+
fn apply_layout(mut body: String, s: &Spec) -> String {
255+
let width = match s.width {
256+
Some(w) => w,
257+
None => return body,
258+
};
259+
let pad = width.saturating_sub(body.chars().count());
260+
if pad == 0 {
261+
return body;
262+
}
263+
let (fill, align) = if s.zero {
264+
('0', s.align.unwrap_or('='))
265+
} else {
266+
(s.fill, s.align.unwrap_or('>'))
267+
};
268+
let pad_str: String = std::iter::repeat_n(fill, pad).collect();
269+
match align {
270+
'<' => body.push_str(&pad_str),
271+
'>' => body = format!("{pad_str}{body}"),
272+
'^' => {
273+
let half = pad / 2;
274+
body = format!(
275+
"{}{}{}",
276+
std::iter::repeat_n(fill, half).collect::<String>(),
277+
body,
278+
std::iter::repeat_n(fill, pad - half).collect::<String>()
279+
);
280+
}
281+
'=' => {
282+
// padding after the sign
283+
let (sign, rest) = if body.starts_with('-') || body.starts_with('+') {
284+
body.split_at(1)
285+
} else {
286+
("", body.as_str())
287+
};
288+
body = format!("{sign}{pad_str}{rest}");
289+
}
290+
_ => {}
291+
}
292+
body
293+
}
294+
295+
/// Normalize dashu's bare scientific exponent (`1.5e0`) to CPython's form (`1.5e+00`).
296+
fn normalize_sci(s: String, upper: bool) -> String {
297+
let idx = s.find(['e', 'E']);
298+
match idx {
299+
Some(i) => {
300+
let (head, tail) = s.split_at(i);
301+
let exp_part = &tail[1..];
302+
let exp: isize = exp_part.parse().unwrap_or(0);
303+
let marker = if upper { 'E' } else { 'e' };
304+
format!("{head}{marker}{exp:+03}")
305+
}
306+
None => s,
307+
}
308+
}

python/src/int.rs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -408,9 +408,11 @@ impl UPy {
408408
fn __str__(&self) -> String {
409409
format!("{}", self.0)
410410
}
411-
fn __format__(&self, _format_spec: &str) -> String {
412-
// MVP: ignore the format mini-language and delegate to Display.
413-
format!("{}", self.0)
411+
fn __format__(&self, format_spec: &str, py: Python<'_>) -> PyResult<String> {
412+
// delegate to Python int (arbitrary precision — no loss)
413+
convert_from_ubig(&self.0, py)?
414+
.call_method1("__format__", (format_spec,))?
415+
.extract::<String>()
414416
}
415417
fn __hash__(&self) -> u64 {
416418
let mut hasher = DefaultHasher::new();
@@ -880,9 +882,11 @@ impl IPy {
880882
fn __str__(&self) -> String {
881883
format!("{}", self.0)
882884
}
883-
fn __format__(&self, _format_spec: &str) -> String {
884-
// MVP: ignore the format mini-language and delegate to Display.
885-
format!("{}", self.0)
885+
fn __format__(&self, format_spec: &str, py: Python<'_>) -> PyResult<String> {
886+
// delegate to Python int (arbitrary precision — no loss)
887+
convert_from_ibig(&self.0, py)?
888+
.call_method1("__format__", (format_spec,))?
889+
.extract::<String>()
886890
}
887891
fn __hash__(&self) -> u64 {
888892
let mut hasher = DefaultHasher::new();

python/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ mod cache;
88
mod complex;
99
mod convert;
1010
mod float;
11+
mod format;
1112
mod int;
1213
mod math;
1314
mod rational;

0 commit comments

Comments
 (0)