Repository: mpmath/mpmath
Pull request #841 (2024-08-17 04:44:52)

Implement 'a'/'A' formating types for mpf.__format__
See #337

skirpichev: See also https://github.com/python/cpython/issues/113804.  Potentially, we could reuse 'x'/'X' format codes, but I think it's a good idea to be compatible with gmpy2 (and C) here.

@javierelpianista ?
javierelpianista:  
I

> See also [python/cpython#113804](https://github.com/python/cpython/issues/113804). Potentially, we could reuse 'x'/'X' format codes, but I think it's a good idea to be compatible with gmpy2 (and C) here.
> 
> @javierelpianista ?

Yes, I think it's better to use 'a' and 'A'. 
skirpichev: > print(f"{gmpy2.mpfr('1.234567890123456789'):.0a}") # This one is different

It's not.  The mpmath always use 1-normalization for normal numbers, while in the MPFR - the digit before decimal dot should be just non-zero.  This is something they wish to fix, but...  See https://sympa.inria.fr/sympa/arc/mpfr/2021-05/msg00002.html

Patch:
diff --git a/mpmath/ctx_mp_python.py b/mpmath/ctx_mp_python.py
index 62a1f9a..163b858 100644
--- a/mpmath/ctx_mp_python.py
+++ b/mpmath/ctx_mp_python.py
@@ -398,6 +398,22 @@ def __format__(s, format_spec):
             '-1.23456'
             >>> f'{x:.5Df}'
             '-1.23457'
+
+        Format types 'a' and 'A' (use uppercase digits) allow to represent
+        floating-point number as a C99-style hexadecimal string
+        ``[±][0x]h[.hhh]p±d``, where there is one hexadecimal digit before the
+        dot and the fractional part either is exact or the number of its
+        hexadecimal digits is equal to the specified precision.  The exponent
+        ``d`` is written in decimal, it always contains at least one digit, and
+        it gives the power of 2 by which to multiply the coefficient.  If no
+        digits follow the decimal point, the decimal point is also removed
+        unless the ``#`` option is specified.
+
+            >>> f'{x:a}'
+            '-0x1.3c0ca2a5b1d5d0818d3359c99ff1a26f2b31063249p+0'
+            >>> f'{x:.10a}'
+            '-0x1.3c0ca2a5b2p+0'
+
         """
 
         _, _, (prec, _) = s._ctxdata
diff --git a/mpmath/libmp/libmpf.py b/mpmath/libmp/libmpf.py
index 48394fe..66ae305 100644
--- a/mpmath/libmp/libmpf.py
+++ b/mpmath/libmp/libmpf.py
@@ -1404,7 +1404,7 @@ def from_str(x, prec=0, rnd=round_fast, base=0):
     (?P<thousands_separators>[,_])?
     (?:\.(?P<precision>0|[1-9][0-9]*))?
     (?P<rounding>[UDYZN])?
-    (?P<type>[eEfFgG%])?
+    (?P<type>[aAeEfFgG%])?
 """, re.DOTALL | re.VERBOSE).fullmatch
 
 _GMPY_ROUND_CHAR_DICT = {
@@ -1480,7 +1480,7 @@ def read_format_spec(format_spec):
             format_dict['align'] = '='
             format_dict['fill_char'] = '0'
 
-        if format_dict['precision'] < 0 and format_dict['type']:
+        if format_dict['precision'] < 0 and format_dict['type'].lower() not in ['', 'a']:
             format_dict['precision'] = 6
     else:
         raise ValueError("Invalid format specifier '{}'".format(format_spec))
@@ -1620,13 +1620,66 @@ def format_scientific(s,
     return sign, digits + sep + f'{exponent:+03d}'
 
 
+def format_hexadecimal(s,
+                       precision=None,
+                       strip_zeros=False,
+                       sign_spec='-',
+                       base=16,
+                       capitalize=False,
+                       alternate=False,
+                       rounding=round_nearest):
+    sep = 'P' if capitalize else 'p'
+
+    if precision < 0:
+        precision = s[1].bit_length()//4 + 1
+
+    # First, get the exponent to know how many digits we will need
+    dps = precision+1
+    sign, digits, exponent = to_digits_exp(
+            s, max(dps+10, int(s[3]/4)+10), base)
+    exponent *= 4
+
+    if sign != '-' and sign_spec != '-':
+        sign = sign_spec
+
+    # normalization
+    if int(digits[0], 16) > 1:
+        shift = math.floor(math.log2(int(digits[0], 16)))
+        exponent += shift
+        n = int(digits, 16) >> shift
+        digits = hex(n)[2:]
+
+    if digits != "0":
+        digits, exp_add = round_digits(s[0], digits, dps, base, rounding)
+        exponent += exp_add*4
+
+    # normalization
+    if digits[0] == "2":
+        exponent += 1
+        n = int(digits, 16) >> 1
+        digits = hex(n)[2:]
+
+    if s[1] and strip_zeros:
+        # Clean up trailing zeros
+        digits = digits.rstrip('0')
+        precision = len(digits)
+
+    if precision >= 1 and len(digits) > 1:
+        return sign, digits[0] + '.' + digits[1:] + sep + f'{exponent:+01d}'
+
+    if alternate:
+        return sign, digits + '.' + sep + f'{exponent:+01d}'
+
+    return sign, digits + sep + f'{exponent:+01d}'
+
+
 _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 list('FGE'):
+    if format_dict['type'] in list('AFGE'):
         capitalize = True
 
     fmt_type = format_dict['type'].lower()
@@ -1696,6 +1749,19 @@ def format_digits(num, format_dict, prec):
         if digits[0] in 'eE':
             digits = '0' + digits
 
+    elif fmt_type == 'a':
+        sign, digits = format_hexadecimal(
+                num,
+                precision=precision,
+                strip_zeros=True,
+                sign_spec=format_dict['sign'],
+                base=16,
+                capitalize=capitalize,
+                alternate=format_dict['alternate'],
+                rounding=rounding
+                )
+        digits = ('0X' if capitalize else '0x') + digits
+
     else:  # fixed-point format
         sign, digits = format_fixed(
                 num,


Test patch:
diff --git a/mpmath/tests/test_format.py b/mpmath/tests/test_format.py
index dfb6b1a..0ce77e0 100644
--- a/mpmath/tests/test_format.py
+++ b/mpmath/tests/test_format.py
@@ -1,3 +1,4 @@
+import ctypes
 import math
 import platform
 import random
@@ -5,7 +6,7 @@
 
 import hypothesis.strategies as st
 import pytest
-from hypothesis import given, settings
+from hypothesis import example, given, settings
 
 from mpmath import fp, inf, mp, nan, ninf, workdps
 from mpmath.libmp.libmpf import read_format_spec
@@ -887,3 +888,50 @@ def test_errors():
     with pytest.raises(ValueError, match="Cannot specify both 0-padding "
                        "and a fill character"):
         f"{mp.mpf('4'):q<03f}"
+
+
+
+@settings(max_examples=10000)
+@given(st.floats(allow_nan=True, allow_infinity=True,
+                 allow_subnormal=False))
+@example(float('nan'))
+@example(float('inf'))
+def test_hexadecimal_bulk(x):
+    if math.isnan(x):
+        assert math.isnan(float.fromhex(f"{mp.mpf(x):a}"))
+    else:
+        assert float.fromhex(f"{mp.mpf(x):a}") == x
+
+
+try:
+    libc = ctypes.CDLL("libc.so.6")
+except OSError:
+    libc = None
+
+
+def float_print(d, i):
+    fmt = "%." + str(i) + "a\n"
+    a = ctypes.create_string_buffer(256)
+    libc.sprintf(a, bytes(fmt, 'utf-8'), ctypes.c_double(d))
+    return a.raw.decode('utf-8').split("\n")[0]
+
+
+@pytest.mark.skipif(libc is None, reason='requires libc')
+@settings(max_examples=10000)
+@given(st.floats(allow_nan=False, allow_infinity=False,
+                 allow_subnormal=False),
+       st.integers(min_value=0, max_value=15))
+def test_hexadecimal_with_libc_bulk(x, p):
+    fmt = '.' + str(p) + 'a'
+    x_hex = float_print(x, p)
+    m_hex = format(mp.mpf(x), fmt)
+    assert mp.mpf(m_hex) == mp.mpf(x_hex)
+
+
+def test_hexadecimal():
+    with workdps(1000):
+        x = mp.mpf('1.234567890123456789')
+        assert f'{x:.20a}' == '0x1.3c0ca428c59fb71a4194p+0'
+        assert f'{x:.0a}' == '0x1p+0'
+        assert f'{x:#.0a}' == '0x1.p+0'
+        assert f"{mp.mpf('1.234567890123456789'):+.0a}" == '+0x1p+0'