diff --git a/conftest.py b/conftest.py index 8816adbc..1b0843f1 100644 --- a/conftest.py +++ b/conftest.py @@ -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 diff --git a/docs/basics.rst b/docs/basics.rst index 5ceb1b88..a02a4373 100644 --- a/docs/basics.rst +++ b/docs/basics.rst @@ -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)))``. diff --git a/mpmath/__main__.py b/mpmath/__main__.py index 1cb1391b..ff53a49a 100644 --- a/mpmath/__main__.py +++ b/mpmath/__main__.py @@ -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 diff --git a/mpmath/ctx_mp.py b/mpmath/ctx_mp.py index a0550a5d..a4d3ca1f 100644 --- a/mpmath/ctx_mp.py +++ b/mpmath/ctx_mp.py @@ -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) diff --git a/mpmath/ctx_mp_python.py b/mpmath/ctx_mp_python.py index 8c07ad56..ce4ebad5 100644 --- a/mpmath/ctx_mp_python.py +++ b/mpmath/ctx_mp_python.py @@ -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) diff --git a/mpmath/libmp/libmpf.py b/mpmath/libmp/libmpf.py index 348e94c2..a131606b 100644 --- a/mpmath/libmp/libmpf.py +++ b/mpmath/libmp/libmpf.py @@ -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,88 @@ 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)). + k = 0 + while rev_cmp(R + Mplus, S): + k -= 1 + R *= base + Mplus *= base + Mminus *= base + while cmp(S*base, R + Mplus): + k += 1 + S *= 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 +1656,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,6 +1670,8 @@ 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 = '' @@ -1598,7 +1683,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 +1710,26 @@ def format_digits(num, format_dict, prec, rnd, _pretty_repr_dps): if capitalize: frac_part = frac_part.upper() + elif unique: + # 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: @@ -1704,9 +1809,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 +1825,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 +1839,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: diff --git a/mpmath/tests/test_cli.py b/mpmath/tests/test_cli.py index 15a5cc53..01ade9e5 100644 --- a/mpmath/tests/test_cli.py +++ b/mpmath/tests/test_cli.py @@ -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 diff --git a/mpmath/tests/test_format.py b/mpmath/tests/test_format.py index e80ac9a9..18aa3960 100644 --- a/mpmath/tests/test_format.py +++ b/mpmath/tests/test_format.py @@ -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}') diff --git a/mpmath/tests/test_str.py b/mpmath/tests/test_str.py index 2b315a4a..10d2a458 100644 --- a/mpmath/tests/test_str.py +++ b/mpmath/tests/test_str.py @@ -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,93 @@ 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(10000): + 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)]) + 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))