Repository: mpmath/mpmath (python)

Issue:
format(mp.mpf(0.99999), '.4f') returns '0.99999'
Should be '1.0000'.

I would guess this is related to "a hack" here:
https://github.com/mpmath/mpmath/blob/9d9029a5592124670dd6efd89c26b0c2304d272e/mpmath/libmp/libmpf.py#L1519-L1522

CC @javierelpianista 

Pull request:
Add hypothesis-based formatting tests and workaround #858
* fix also few other minor issues, coming from tests, closes #858
* simplify handling '%' format type
* split out format_digits() and redo handling special numbers

Patch:
diff --git a/mpmath/libmp/libmpf.py b/mpmath/libmp/libmpf.py
index 1c1b7d5..09d24d0 100644
--- a/mpmath/libmp/libmpf.py
+++ b/mpmath/libmp/libmpf.py
@@ -1055,7 +1055,7 @@ def to_digits_exp(s, dps, base=10):
     _sign, man, exp, bc = s
 
     if not man:
-        return '', '0', 0
+        return '', '0'*int(dps), 0
 
     if base == 10:
         blog2 = blog2_10
@@ -1098,7 +1098,8 @@ def to_digits_exp(s, dps, base=10):
     exponent += len(digits) - fixdps - 1
     return sign, digits, exponent
 
-def round_digits(sign, digits, dps, base, rounding=round_nearest):
+def round_digits(sign, digits, dps, base, rounding=round_nearest,
+                 hack0=False):
     '''
     Returns the rounded digits, and the number of places the decimal point was
     shifted.
@@ -1170,7 +1171,8 @@ def round_digits(sign, digits, dps, base, rounding=round_nearest):
             digits = digits[:i] + stddigits[int(digits[i], base) + 1] + \
                 '0' * (dps - i - 1)
         else:
-            digits = '1' + '0' * (dps - 1)
+            # When rounding up 0.9999... in fixed format, we lose one dps.
+            digits = '1' + '0' * (dps - (0 if hack0 else 1))
             exponent += 1
     else:
         digits = digits[:dps]
@@ -1493,8 +1495,7 @@ def format_fixed(s,
                  base=10,
                  alternate=False,
                  no_neg_0=False,
-                 percent=False,
-                 prec=0,
+                 hack0=True,
                  rounding=round_nearest):
     '''
     Format a number into fixed point.
@@ -1503,9 +1504,6 @@ def format_fixed(s,
     Does not perform padding or aligning
     '''
 
-    if percent:
-        s = mpf_mul(s, from_int(100), prec=prec, rnd=round_nearest)
-
     # First, get the exponent to know how many digits we will need
     _, _, exponent = to_digits_exp(s, 1, base)
 
@@ -1516,11 +1514,6 @@ def format_fixed(s,
             s, max(precision+exponent+4, int(s[3]/blog2_10)), base)
     dps = precision + exponent + 1
 
-    # Hack: if the digits are all 9s, then we will lose one dps when rounding
-    # up.
-    if all(dig == stddigits[base-1] for dig in digits[:dps+1]):
-        dps += 1
-
     if sign != '-' and sign_spec != '-':
         sign = sign_spec
 
@@ -1535,9 +1528,12 @@ def format_fixed(s,
         if no_neg_0:
             sign = '' if sign_spec == '-' else sign_spec
     else:
-        digits, exp_add = round_digits(s[0], digits, dps, base, rounding)
+        digits, exp_add = round_digits(s[0], digits, dps, base, rounding, hack0)
         exponent += exp_add
 
+        if all(_ == '0' for _ in digits) and no_neg_0:
+            sign = '' if sign_spec == '-' else sign_spec
+
         # Here we prepend the corresponding 0s to the digits string, according
         # to the value of exponent
         if exponent < 0:
@@ -1578,8 +1574,8 @@ def format_fixed(s,
     if digits[-1] == "." and strip_last_zero:
         digits = digits[:-1]
 
-    if percent:
-        digits = digits + '%'
+    if alternate and '.' not in digits:
+        digits += '.'
 
     return sign, digits
 
@@ -1621,9 +1617,11 @@ def format_scientific(s,
     return sign, digits + sep + f'{exponent:+03d}'
 
 
-def format_mpf(num, format_spec, prec):
-    format_dict = read_format_spec(format_spec)
+_MAP_SPEC_STR = {finf: ('', 'inf'), fninf: ('-', 'inf'), fnan: ('', 'nan')}
 
+
+def format_digits(num, format_dict, prec):
+    hack0 = True
     capitalize = False
     if format_dict['type'] in 'FGE':
         capitalize = True
@@ -1633,16 +1631,13 @@ def format_mpf(num, format_spec, prec):
     if fmt_type == '%':
         percent = True
         fmt_type = 'f'
+        num = mpf_mul(num, from_int(100), prec=prec, rnd=round_nearest)
 
     precision = format_dict['precision']
 
     digits = ''
     sign = ''
 
-    # Special cases:
-    if num in (fnan, finf, fninf, fzero, fnzero):
-        return format(to_float(num), format_spec)
-
     # Now the general case
     strip_last_zero = False
     strip_zeros = False
@@ -1650,6 +1645,7 @@ def format_mpf(num, format_spec, prec):
     rounding = format_dict['rounding']
 
     if fmt_type == 'g':
+        hack0 = False
         if not format_dict['alternate']:
             strip_last_zero = True
             strip_zeros = True
@@ -1665,7 +1661,16 @@ def format_mpf(num, format_spec, prec):
             fmt_type = 'e'
             precision = max(0, precision - 1)
 
-    if fmt_type == 'f':
+    if num in _MAP_SPEC_STR:  # special cases
+        sign, digits = _MAP_SPEC_STR[num]
+        if capitalize:
+            digits = digits.upper()
+        if sign != '-' and format_dict['sign'] != '-':
+            sign = format_dict['sign']
+        if percent:
+            digits += '%'
+
+    elif fmt_type == 'f':
         sign, digits = format_fixed(
                 num,
                 precision=precision,
@@ -1677,10 +1682,11 @@ def format_mpf(num, format_spec, prec):
                 base=10,
                 alternate=format_dict['alternate'],
                 no_neg_0=format_dict['no_neg_0'],
-                percent=percent,
-                prec=prec,
+                hack0=hack0,
                 rounding=rounding
                 )
+        if percent:
+            digits = digits + '%'
     else:  # The format type is scientific
         sign, digits = format_scientific(
                 num,
@@ -1693,6 +1699,14 @@ def format_mpf(num, format_spec, prec):
                 rounding=rounding
                 )
 
+    return sign, digits
+
+
+def format_mpf(num, format_spec, prec):
+    format_dict = read_format_spec(format_spec)
+
+    sign, digits = format_digits(num, format_dict, prec)
+
     nchars = len(digits) + len(sign)
 
     lpad, rpad = calc_padding(


Test patch:
diff --git a/mpmath/tests/test_format.py b/mpmath/tests/test_format.py
index 6d2e477..de2b377 100644
--- a/mpmath/tests/test_format.py
+++ b/mpmath/tests/test_format.py
@@ -1,7 +1,11 @@
+import math
+import platform
 import random
 import sys
 
+import hypothesis.strategies as st
 import pytest
+from hypothesis import given, settings
 
 from mpmath import fp, inf, mp, nan, ninf, workdps
 
@@ -69,6 +73,60 @@ def random_fmt():
     return fmt_str
 
 
+@st.composite
+def fmtstr(draw, types='fFeE'):
+    res = ''
+
+    # fill_char and align
+    fill_char = draw(st.sampled_from(['']*3 + list('z;clxvjqwer')))
+    if fill_char:
+        skip_0_padding = True
+        align = draw(st.sampled_from(list('<^>=')))
+        res += fill_char + align
+    else:
+        align = draw(st.sampled_from([''] + list('<^>=')))
+        if align:
+            skip_0_padding = True
+            res += align
+        else:
+            skip_0_padding = False
+
+    # sign character
+    res += draw(st.sampled_from([''] + list('-+ ')))
+
+    # no_neg_0 (not used yet.)
+    if sys.version_info[:3] > (3, 11):
+        res += draw(st.sampled_from([''] + ['z']))
+
+    # alternate mode
+    res += draw(st.sampled_from(['', '#']))
+
+    # pad with 0s
+    pad0 = draw(st.sampled_from(['', '0']))
+    skip_thousand_separators = False
+    if pad0 and not skip_0_padding:
+        res += pad0
+        skip_thousand_separators = True
+
+    # Width
+    res += draw(st.sampled_from(['']*7 + list(map(str, range(1, 40)))))
+
+    # grouping character (thousand_separators)
+    gchar = draw(st.sampled_from([''] + list(',_')))
+    if gchar and not skip_thousand_separators:
+        res += gchar
+
+    # Precision
+    prec = draw(st.sampled_from(['']*7 + list(map(str, range(40)))))
+    if prec:
+        res += '.' + prec
+
+    # Type
+    res += draw(st.sampled_from(types))
+
+    return res
+
+
 def test_mpf_fmt_cpython():
     '''
     These tests assure that mpf.__format__ yields the same result as regular
@@ -442,6 +500,18 @@ def test_mpf_fmt_cpython():
     assert f'{mp.mpf(234.56):#.5g}' == '234.56'
     assert f'{mp.mpf(234.56):#.6g}' == '234.560'
 
+    # '%' code formatting.
+
+    # nan, infs
+    assert f'{inf:%}' == 'inf%'
+    assert f'{inf:+%}' == '+inf%'
+
+    assert f'{ninf:%}' == '-inf%'
+    assert f'{ninf:+%}' == '-inf%'
+
+    assert f'{nan:%}' == 'nan%'
+    assert f'{nan:+%}' == '+nan%'
+
 
 def test_mpf_float():
     '''
@@ -465,19 +535,52 @@ def test_mpf_float():
         fmt_str = random_fmt()
         num = fp.mpf('{:.15f}e{:d}'.format(
                      random.uniform(1, 10), random.randint(-324, -308)))
+        if random.randint(0, 1):
+            num = -num
 
         # We skip the case when num == 0 since mpmath does not have negative
         # zero.
-        if num != 0 and random.randint(0, 1):
-            num = -num
+        if num == 0:
+            continue
+
         assert fmt_str.format(num) == fmt_str.format(mp.mpf(num))
 
+
     # Test the same random formats with special numbers
+    if (',' in fmt_str or '_' in fmt_str) and platform.python_implementation() == 'PyPy':
+        return  # see pypy/pypy#5018
+
     for num in (inf, -inf, nan, 0):
         fmt_str = random_fmt()
         assert fmt_str.format(fp.mpf(num)) == fmt_str.format(mp.mpf(num))
 
 
+@settings(max_examples=10000)
+@given(fmtstr(),
+       st.floats(allow_nan=True,
+                 allow_infinity=True,
+                 allow_subnormal=True))
+def test_mpf_floats_fFeE(fmt, x):
+    if not x and math.copysign(1, x) == -1:
+        return  # skip negative zero
+    if (',' in fmt or '_' in fmt) and platform.python_implementation() == 'PyPy':
+        return  # see pypy/pypy#5018
+    assert format(x, fmt) == format(mp.mpf(x), fmt)
+
+
+@settings(max_examples=10000)
+@given(fmtstr('gG'),
+       st.floats(allow_nan=True,
+                 allow_infinity=True,
+                 allow_subnormal=True))
+def test_mpf_floats_gG(fmt, x):
+    if not x and math.copysign(1, x) == -1:
+        return  # skip negative zero
+    if (',' in fmt or '_' in fmt) and platform.python_implementation() == 'PyPy':
+        return  # see pypy/pypy#5018
+    assert format(x, fmt) == format(mp.mpf(x), fmt)
+
+
 def test_mpf_fmt():
     '''
     These tests are either specific tests to mpf, or tests that cover
@@ -731,6 +834,16 @@ def test_mpf_fmt():
             assert f"{mp.mpf('-0.1'):=.4Df}" == "-0.1000"
 
 
+def test_issue_858():
+    for n in range(2, 15):
+        str_num = '0.' + (n)*'9'
+        fmt_str = '.' + str(n-1) + 'f'
+
+        assert format(mp.mpf(str_num), fmt_str) == format(fp.mpf(str_num), fmt_str)
+
+    assert format(mp.mpf(0.96875), '#.0g') == '1.'
+
+
 def test_errors():
     with pytest.raises(ValueError):
         # wrong format type