Merge pull request #1115 from skirpichev/shortest-repr/1028

Use shortest decimal representations for str/repr
This commit is contained in:
Sergey B Kirpichev
2026-08-21 04:34:47 +03:00
committed by GitHub
9 changed files with 335 additions and 17 deletions
+1
View File
@@ -31,5 +31,6 @@ def reset_mp_globals():
mpmath.mp.pretty = False
mpmath.mp.rounding = 'n'
mpmath.mp.pretty_dps = "str"
mpmath.mp.shortest_str = False
mpmath.iv.prec = mpmath.mp.prec
mpmath.iv.pretty = False
+21 -1
View File
@@ -83,6 +83,7 @@ Mpmath uses a global working precision; it does not keep track of the precision
mp.rounding = 'n' [default: 'n']
mp.trap_complex = False [default: False]
mp.pretty_dps = 'str' [default: 'str']
mp.shortest_str = False [default: False]
The term **prec** denotes the binary precision (measured in bits) while **dps** (short for *decimal places*) is the decimal precision. Binary and decimal precision are related roughly according to the formula ``prec = 3.33*dps``. For example, it takes a precision of roughly 333 bits to hold an approximation of pi that is accurate to 100 decimal places (actually slightly more than 333 bits is used).
@@ -234,7 +235,26 @@ Setting the ``mp.pretty`` option will use the ``str()``-style output for ``repr(
>>> mpf(0.6)
mpf('0.59999999999999998')
To use enough digits to be able recreate value exactly, set ``mp.pretty_dps``
To use enough digits to be able recreate value exactly, enable
``mp.shortest_str`` option. With this, repr/str and the new-style string
formatting *without format specifier* will use *minimal* number of decimal
digits that will preserve value on string input, like repr for CPython's
builtin floats:
>>> mp.shortest_str = True
>>> mp.pretty = True
>>> mpf(10.9) == mpf("10.9")
True
>>> mpf(10.9)
10.9
>>> f"{_}"
'10.9'
>>> mp.pretty = False
>>> mpf(10.9)
mpf('10.9')
>>> mp.shortest_str = False
Alternatively, set ``mp.pretty_dps``
to ``"repr"`` (default value is ``"str"``). Same option is used to control
default number of digits in the new-style string formatting *without format
specifier*, i.e. ``format(exp(mpf(1)))``.
+4
View File
@@ -43,6 +43,8 @@ parser.add_argument('--no-pretty', help='Disable pretty-printing',
parser.add_argument('--int-limits',
help="Enable string conversion length limitation for int's",
action='store_true')
parser.add_argument('--shortest-str', help='Use shortest str/repr',
action='store_true')
def main():
@@ -64,6 +66,8 @@ def main():
if not args.no_pretty:
lines.append('mp.pretty = True')
lines.append('mp.pretty_dps = "repr"')
if args.shortest_str:
lines.append('mp.shortest_str = True')
try:
import IPython
+2
View File
@@ -52,6 +52,7 @@ class MPContext(BaseMPContext, StandardBaseContext):
rounding=round_nearest, trap_complex=False):
BaseMPContext.__init__(ctx)
ctx.pretty = False
ctx.shortest_str = False
ctx.types = [ctx.mpf, ctx.mpc, ctx.constant]
ctx.default()
ctx._set_prec(prec)
@@ -372,6 +373,7 @@ class MPContext(BaseMPContext, StandardBaseContext):
(" mp.rounding = '%s'" % ctx.rounding).ljust(30) + f"[default: 'n']",
(" mp.trap_complex = %s" % ctx.trap_complex).ljust(30) + "[default: False]",
(" mp.pretty_dps = '%s'" % ctx.pretty_dps).ljust(30) + "[default: 'str']",
(" mp.shortest_str = %s" % ctx.shortest_str).ljust(30) + "[default: False]",
]
return "\n".join(lines)
+19 -2
View File
@@ -136,13 +136,22 @@ class _mpf(mpnumeric):
def __repr__(self):
ctx = self.context
if ctx.pretty:
if ctx.shortest_str:
return str(self)
ndigits = (ctx._repr_digits
if ctx._pretty_repr_dps else ctx._str_digits)
return to_str(self._mpf_, ndigits)
prec, rounding = ctx._prec_rounding
if ctx.shortest_str:
return f"mpf({format_mpf(self._mpf_, '', prec, rounding, ctx._pretty_repr_dps, True)!r})"
return f"mpf({to_str(self._mpf_, ctx._repr_digits)!r})"
def __str__(self):
ctx = self.context
if ctx.shortest_str:
prec, rounding = ctx._prec_rounding
return format_mpf(self._mpf_, '', prec, rounding,
ctx._pretty_repr_dps, True)
return to_str(self._mpf_, ctx._str_digits)
def __hash__(self): return mpf_hash(self._mpf_)
@@ -450,7 +459,8 @@ class _mpf(mpnumeric):
_, _, (prec, rounding) = self._ctxdata
ctx = self.context
return format_mpf(self._mpf_, format_spec, prec, rounding,
ctx._pretty_repr_dps)
ctx._pretty_repr_dps,
ctx.shortest_str)
def sqrt(self):
ctx = self.context
@@ -547,6 +557,8 @@ class _mpc(mpnumeric):
def __repr__(self):
ctx = self.context
if ctx.pretty:
if ctx.shortest_str:
return str(self)
ndigits = (ctx._repr_digits
if ctx._pretty_repr_dps else ctx._str_digits)
return f"({mpc_to_str(self._mpc_, ndigits)})"
@@ -556,6 +568,10 @@ class _mpc(mpnumeric):
def __str__(self):
ctx = self.context
if ctx.shortest_str:
prec, rounding = ctx._prec_rounding
return format_mpc(self._mpc_, '', prec, rounding,
ctx._pretty_repr_dps, True)
return f"({mpc_to_str(self._mpc_, ctx._str_digits)})"
def __complex__(self):
@@ -761,7 +777,8 @@ class _mpc(mpnumeric):
ctx = self.context
_, _, (prec, rounding) = self._ctxdata
return format_mpc(self._mpc_, format_spec, prec, rounding,
ctx._pretty_repr_dps)
ctx._pretty_repr_dps,
ctx.shortest_str)
complex_types = (complex, _mpc)
+160 -10
View File
@@ -3,6 +3,7 @@ Low-level functions for arbitrary-precision floating-point arithmetic.
"""
import math
import operator
import random
import re
import sys
@@ -1025,6 +1026,97 @@ def mpf_perturb(x, eps_sign, prec, rnd):
# Radix conversion #
#----------------------------------------------------------------------------#
stddigits_as_bytes = bytearray(stddigits.encode('ascii'))
def fpp2(x, prec=0, base=10):
"""
(FPP)² algorithm from "How to Print Floating-Point Numbers Accurately"
by Steele & White. Assume round_nearest rounding mode.
The output is correctly rounded. Carry doesn't propagate on rounding. The
original x can be recreated, when output submitted to from_str() with
round_nearest rounding. No "garbage digits" produced.
"""
_, man, exp, bc = x
if not man:
assert not exp
return "0", 0
prec = prec if prec else bc
man <<= prec - bc
exp += bc
assert 0 < man < 2**prec
# Original version doesn't implement rounding correctly, we take this
# into account, using strict inequatities for low/high conditions,
# following the Burger & Dybvig Scheme code from "Printing Floating-Point
# Numbers Quickly and Accurately".
is_even = man & 1 == 0
cmp = operator.le if is_even else operator.lt
rev_cmp = operator.lt if is_even else operator.le
# Step 1. Initialize variables.
ep = exp - prec
R = man << max(ep, 0) + 1
S = 1 << max(-ep, 0) + 1
Mminus = Mplus = 1 << max(ep, 0)
if man == 1 << (prec - 1):
Mplus <<= 1
R <<= 1
S <<= 1
# Step 2. Compute ceil(log((R + Mplus)/S, base)).
# We use (undocumented) support for computing logarithms of
# big integers (that overflows floats). This is available
# also on PyPy and GraalPy.
k = math.ceil((math.log2(int(R + Mplus)) -
math.log2(int(S)))/math.log2(int(base)))
if k < 0:
bk = base**-k
R *= bk
Mplus *= bk
Mminus *= bk
if k > 0:
S *= base**k
# k might be either exact or by 1 too big.
if rev_cmp(R + Mplus, S):
k -= 1
R *= base
Mplus *= base
Mminus *= base
assert cmp(S, R + Mplus)
D = bytearray()
# Step 3. Generate digits.
while True:
U, R = divmod(R, S)
low = cmp(R, Mminus)
high = cmp(S, R + Mplus)
D.append(stddigits_as_bytes[U])
if low or high:
# Step 4. Break the loop, round last digit.
round_up = high
if low and high:
round_up = 2*R >= S
# Theorem 4 in the Burger & Dybvig article is invalid,
# and the algorithm actually depends on how the input
# routine break ties. Following code assumes default IEEE
# rounding mode, i.e. the mpmath's round_nearest.
if round_up and 2*R == S:
round_up = U & 1
if round_up:
# But Theorem 1 is still valid: no carry should
# be generated on rounding up.
assert ord('0') <= D[-1] < ord(stddigits[base - 1])
D[-1] += 1
break
R *= base
Mminus *= base
Mplus *= base
return D.decode(), k
def to_digits_exp(s, dps, base=10):
"""Helper function for representing the floating-point number s as
a string with dps digits. Returns (sign, string, exponent) where
@@ -1573,7 +1665,7 @@ def fill_sep(digits, sep, prev, nmod, sep_range):
for pos in range(nmod, len(digits), sep_range))
def format_digits(num, format_dict, prec, rnd, _pretty_repr_dps):
def format_digits(num, format_dict, prec, rnd, _pretty_repr_dps, unique):
capitalize = False
if format_dict['type'] in list('AFGE'):
capitalize = True
@@ -1587,10 +1679,12 @@ def format_digits(num, format_dict, prec, rnd, _pretty_repr_dps):
num = mpf_mul(num, from_int(100), prec, rnd=round_nearest)
dps = format_dict['precision']
if dps >= 0 or fmt_type:
unique = False
int_part = ''
exponent = ''
sign = ''
sign = '-' if num[0] else ''
# Now the general case
strip_last_zero = False
@@ -1598,7 +1692,7 @@ def format_digits(num, format_dict, prec, rnd, _pretty_repr_dps):
rnd = format_dict.get('rounding', rnd)
if not fmt_type or fmt_type == 'g':
if not unique and (not fmt_type or fmt_type == 'g'):
if not format_dict['alternate']:
strip_zeros = True
if fmt_type == 'g':
@@ -1625,6 +1719,51 @@ def format_digits(num, format_dict, prec, rnd, _pretty_repr_dps):
if capitalize:
frac_part = frac_part.upper()
elif unique:
if abs(num[2] + num[3] - prec) > 10000:
dps = repr_dps(prec)
_, digits, exp = to_digits_exp(num, dps, 10)
if len(digits) > dps:
digits, exp = round_digits(num, digits, exp, dps,
10, round_nearest)
prev_digits = digits
prev_exp = exp
while True:
dps -= 1
new_digits, new_exp = round_digits(num, digits, exp, dps,
10, round_down)
new_str = f"{sign}{new_digits[0]}.{new_digits[1:]}e{exp}"
if from_str(new_str, prec, round_nearest, 10) != num:
new_digits, new_exp = round_digits(num, digits, exp, dps,
10, round_up)
new_str = f"{sign}{new_digits[0]}.{new_digits[1:]}e{exp}"
if from_str(new_str, prec, round_nearest, 10) != num:
digits = prev_digits
exp = prev_exp
break
prev_digits = new_digits
prev_exp = new_exp
else:
num = mpf_pos(num, prec, rnd) # workaround issue 1158
# Here be dragons.
digits, exp = fpp2(num, prec, 10)
split = 1
if exp < -4 or exp > prec_to_dps(prec):
exponent = f'e{exp:+03d}'
else:
digits += "0"*(exp + 2 - len(digits))
if exp < 0:
digits = "0"*(-exp) + digits
else:
split += exp
int_part = digits[:split]
frac_part = digits[split:]
if frac_part or format_dict['alternate']:
frac_part = '.' + frac_part
elif fmt_type == 'e':
int_part, frac_part, exponent = format_scientific(num, dps, rnd=rnd)
if strip_zeros:
@@ -1667,7 +1806,6 @@ def format_digits(num, format_dict, prec, rnd, _pretty_repr_dps):
frac_part = fill_sep(frac_part, sep, frac_part[0], 1, sep_range)
digits = frac_part + exponent
sign = '-' if num[0] else ''
if sign != '-' and format_dict['sign'] != '-':
sign = format_dict['sign']
if fmt_type == 'f' and format_dict['no_neg_0']:
@@ -1704,9 +1842,10 @@ def format_digits(num, format_dict, prec, rnd, _pretty_repr_dps):
return sign, int_part + digits
def format_mpf(num, format_spec, prec, rnd, _pretty_repr_dps):
def format_mpf(num, format_spec, prec, rnd, _pretty_repr_dps, unique):
format_dict = read_format_spec(format_spec)
sign, digits = format_digits(num, format_dict, prec, rnd, _pretty_repr_dps)
sign, digits = format_digits(num, format_dict, prec, rnd,
_pretty_repr_dps, unique)
nchars = len(digits) + len(sign)
lpad, rpad = calc_padding(
nchars, format_dict['width'], format_dict['align'])
@@ -1719,7 +1858,7 @@ def format_mpf(num, format_spec, prec, rnd, _pretty_repr_dps):
+ rpad*format_dict['fill_char']
def format_mpc(num, format_spec, prec, rnd, _pretty_repr_dps):
def format_mpc(num, format_spec, prec, rnd, _pretty_repr_dps, unique):
format_dict = read_format_spec(format_spec)
if format_dict['fill_char'] == '0':
@@ -1733,12 +1872,23 @@ def format_mpc(num, format_spec, prec, rnd, _pretty_repr_dps):
"format specifier.")
fmt_type = format_dict['type'].lower()
if not fmt_type:
if not fmt_type and format_dict['precision'] >= 0:
format_dict['type'] = 'g'
sign_re, digits_re = format_digits(num[0], format_dict, prec, rnd, _pretty_repr_dps)
sign_re, digits_re = format_digits(num[0], format_dict, prec, rnd,
_pretty_repr_dps, unique)
fmt_sign = format_dict['sign']
format_dict['sign'] = '+'
sign_im, digits_im = format_digits(num[1], format_dict, prec, rnd, _pretty_repr_dps)
sign_im, digits_im = format_digits(num[1], format_dict, prec, rnd,
_pretty_repr_dps, unique)
if not format_dict['type']:
if format_dict['alternate']:
if 'e' not in digits_re:
digits_re = digits_re.rstrip('0')
if 'e' not in digits_im:
digits_im = digits_im.rstrip('0')
else:
digits_re = digits_re.removesuffix('.0')
digits_im = digits_im.removesuffix('.0')
digits_im += 'j'
if not fmt_type:
+8
View File
@@ -46,6 +46,14 @@ def test_bare_console_bare_division():
assert c.expect_exact('0.5\r\n>>> ') == 0
def test_bare_console_shortest_str():
c = Console(f'{sys.executable} -m mpmath --no-ipython --shortest-str')
assert c.expect_exact('>>> ') == 0
assert c.send('0.1\r\n') == 5
assert c.expect_exact('0.1\r\n>>> ') == 0
def test_bare_console_without_ipython():
try:
import IPython
+29 -3
View File
@@ -511,18 +511,29 @@ def test_mpf_floats_bulk(fmt, x):
assert format(x, fmt) == format(mp.mpf(x), fmt)
@given(fmt_str(types=['']),
st.floats(allow_nan=True,
allow_infinity=True,
allow_subnormal=False))
@example('', 1000000000000000.0)
def test_mpf_floats_default_bulk(fmt, x):
mp.shortest_str = True
if not x and math.copysign(1, x) == -1:
return # skip negative zero
spec = read_format_spec(fmt)
assert format(x, fmt) == format(mp.mpf(x), fmt)
@given(fmt_str(types=list('gGfFeE') + [''], for_complex=True),
st.complex_numbers(allow_nan=True,
allow_infinity=True,
allow_subnormal=True))
def test_mpc_complexes(fmt, z):
def test_mpc_complexes_bulk(fmt, z):
mp.pretty_dps = "repr"
if ((not z.real and math.copysign(1, z.real) == -1)
or (not z.imag and math.copysign(1, z.imag) == -1)):
return # skip negative zero
spec = read_format_spec(fmt)
if spec['frac_separators'] and sys.version_info < (3, 14):
return # see also python/cpython#130860
if spec['precision'] < 0 and any(math.isfinite(_) for _ in [z.real, z.imag]):
# The mpmath could choose a different decimal
# representative (wrt CPython) for same binary
@@ -535,6 +546,21 @@ def test_mpc_complexes(fmt, z):
assert format(z, fmt) == format(mp.mpc(z), fmt)
@given(fmt_str(types=[''], for_complex=True),
st.complex_numbers(allow_nan=True,
allow_infinity=True,
allow_subnormal=False))
@example(fmt='', z=complex(0))
@example(fmt='#', z=complex(0))
def test_mpc_complexes_default_bulk(fmt, z):
mp.shortest_str = True
if ((not z.real and math.copysign(1, z.real) == -1)
or (not z.imag and math.copysign(1, z.imag) == -1)):
return # skip negative zero
spec = read_format_spec(fmt)
assert format(z, fmt) == format(mp.mpc(z), fmt)
def test_mpc_fmt():
pytest.raises(ValueError, lambda: f'{mp.mpc(1j):=10f}')
pytest.raises(ValueError, lambda: f'{mp.mpc(1j):010f}')
+91 -1
View File
@@ -1,7 +1,10 @@
import math
import random
import hypothesis.strategies as st
from hypothesis import example, given
from mpmath import inf, matrix, mp, mpc, nstr
from mpmath import inf, matrix, mp, mpc, mpf, nstr, rand
A1 = matrix([])
@@ -62,8 +65,95 @@ def test_matrix_str():
@example(x=6.170920920537087e+17, rnd='f')
def test_eval_repr_roundtrip(x, rnd):
mp.rounding = rnd
mp.shortest_str = False
mp.pretty = True
mp.pretty_dps = 'repr'
mx = mp.mpf(x)
smx = repr(mx)
assert mx == mp.mpf(smx)
mp.pretty_dps = 'str'
mp.shortest_str = True
smx = repr(mx)
assert mx == mp.mpf(smx)
@given(st.floats(allow_subnormal=False,
allow_nan=False,
allow_infinity=False))
@example(1.0)
@example(-10.0)
@example(3.411330784663857e+16)
@example(5.960464477539063e-08)
@example(562949953421312.2)
def test_float_short_repr(f):
mp.shortest_str = True
if not f and math.copysign(1, f) == -1:
return
s = str(f)
m = mpf(f)
sm = str(m)
assert s == sm
assert f"mpf('{s}')" == repr(m)
assert m == mpf(sm)
@given(st.complex_numbers(allow_subnormal=False,
allow_nan=False,
allow_infinity=False))
@example(1+0.1j)
def test_complex_short_repr(z):
mp.shortest_str = True
mp.pretty = False
if ((not z.real and math.copysign(1, z.real) == -1)
or (not z.imag and math.copysign(1, z.imag) == -1)):
return # skip negative zero
s = str(z)
mz = mpc(z)
smz = str(mz)
assert s == smz
assert f"mpc(real='{mz.real!s}', imag='{mz.imag!s}')" == repr(mz)
assert mz == mpc(smz)
mp.pretty = True
assert smz == repr(mz)
def test_short_repr_specials():
mp.shortest_str = True
assert str(mpf(0)) == '0.0'
assert str(mpf('inf')) == 'inf'
assert str(mpf('-inf')) == '-inf'
assert str(mpf('nan')) == 'nan'
def test_short_repr_roundtrip():
mp.shortest_str = True
for dps in [15, 20, 30, 50, 100, 300]:
with mp.workdps(dps):
for _ in range(1000):
f = random.choice([(rand()-0.5)*2 for _ in range(10)]
+ [(rand()-0.5)*2*10**5 for _ in range(5)]
+ [(rand()-0.5)*2/10**5 for _ in range(5)]
+ [(rand()-0.5)*2*10**100 for _ in range(2)]
+ [(rand()-0.5)*2*10**10000 for _ in range(2)]
+ [(rand()-0.5)*2/10**10000 for _ in range(2)])
s = str(f)
b = mpf(s)
assert f == b # round-trip
integer, *frac = s.split('.')
if not frac:
continue
frac = frac[0]
if len(frac) < 2:
continue
frac, *exponent = frac.split('e')
exponent = 'e' + exponent[0] if exponent else ''
# round-trip:
assert f == mpf(str(integer + '.' + frac + exponent))
# test that short repr is really minimal
frac = frac[:-1]
for d in range(10):
frac = frac[:-1] + str(d)
assert f != mpf(str(integer + '.' + frac + exponent))