Repository: mpmath/mpmath (python)

Issue:
hex representation
It would be nice to be able to convert mpmath floats to and from a string hex representation (like `float.hex` and `float.fromhex`). 

Pull request description:
Support binary/hexadecimal string output
Adapted from #414.

Closes #345

Co-authored-by: Sergey B Kirpichev <skirpichev@gmail.com>

* this version doesn't touch argument names (dps->ps)
* ~~no support for float.hex()-style binary exponents~~

PS: `from_str()` part was implemented in #703.  @asmeurer, I would appreciate your review, as you've opened #345.

Patch:
diff --git a/mpmath/ctx_mp_python.py b/mpmath/ctx_mp_python.py
index 5f72a76..a46cf85 100644
--- a/mpmath/ctx_mp_python.py
+++ b/mpmath/ctx_mp_python.py
@@ -37,11 +37,13 @@ def __new__(cls, val=fzero, **kwargs):
         or a decimal string representing a number in floating-point
         format."""
         prec, rounding = cls.context._prec_rounding
+        base = 0
         if kwargs:
             prec = kwargs.get('prec', prec)
             if 'dps' in kwargs:
                 prec = dps_to_prec(kwargs['dps'])
             rounding = kwargs.get('rounding', rounding)
+            base = kwargs.get('base', base)
         v = new(cls)
         if type(val) is cls:
             val = val._mpf_
@@ -54,15 +56,15 @@ def __new__(cls, val=fzero, **kwargs):
             else:
                 raise ValueError
         else:
-            val = cls.mpf_convert_arg(val, prec, rounding)
+            val = cls.mpf_convert_arg(val, prec, rounding, base)
         v._mpf_ = mpf_pos(val, prec, rounding)
         return v
 
     @classmethod
-    def mpf_convert_arg(cls, x, prec, rounding):
+    def mpf_convert_arg(cls, x, prec, rounding, base):
         if isinstance(x, int_types): return from_int(x)
         if isinstance(x, float): return from_float(x)
-        if isinstance(x, str): return from_str(x, prec, rounding)
+        if isinstance(x, str): return from_str(x, prec, rounding, base)
         if isinstance(x, cls.context.constant): return x.func(prec, rounding)
         if isinstance(x, numbers.Rational): return from_rational(x.numerator,
                                                                  x.denominator,
diff --git a/mpmath/libmp/libmpf.py b/mpmath/libmp/libmpf.py
index 3d944a5..5123a89 100644
--- a/mpmath/libmp/libmpf.py
+++ b/mpmath/libmp/libmpf.py
@@ -15,7 +15,7 @@
                       MPZ_TWO, MPZ_ZERO, STRICT, gmpy)
 from .libintmath import (bctable, bin_to_radix, giant_steps, isqrt, isqrt_fast,
                          lshift, numeral, rshift, sqrt_fixed, sqrtrem,
-                         trailing, trailtable)
+                         stddigits, trailing, trailtable)
 
 
 class ComplexResult(ValueError):
@@ -1024,13 +1024,13 @@ def mpf_perturb(x, eps_sign, prec, rnd):
 #                              Radix conversion                              #
 #----------------------------------------------------------------------------#
 
-def to_digits_exp(s, dps):
+def to_digits_exp(s, dps, base=10):
     """Helper function for representing the floating-point number s as
-    a decimal with dps digits. Returns (sign, string, exponent) where
-    sign is '' or '-', string is the digit string, and exponent is
-    the decimal exponent as an int.
+    a string with dps digits. Returns (sign, string, exponent) where
+    sign is '' or '-', string is the digit string in the given base,
+    and exponent is the exponent as an int.
 
-    If inexact, the decimal representation is rounded toward zero."""
+    If inexact, the string representation is rounded toward zero."""
 
     # Extract sign first so it doesn't mess up the string digit count
     if s[0]:
@@ -1043,12 +1043,19 @@ def to_digits_exp(s, dps):
     if not man:
         return '', '0', 0
 
-    bitprec = int(dps * math.log(10,2)) + 10
+    if base == 10:
+        blog2 = 3.3219280948873626
+    elif pow(2, blog2 := int(math.log2(base))) == base:
+        pass
+    else:
+        raise NotImplementedError
+
+    bitprec = int(dps * blog2) + 10
 
     # Cut down to size
     # TODO: account for precision when doing this
     exp_from_1 = exp + bc
-    if abs(exp_from_1) > 3500:
+    if base == 10 and abs(exp_from_1) > 3500:
         from .libelefun import mpf_ln2, mpf_ln10
 
         # Set b = int(exp * log(2)/log(10))
@@ -1069,19 +1076,19 @@ def to_digits_exp(s, dps):
     # fixed-point number and then converting that number to
     # a decimal fixed-point number.
     fixprec = max(bitprec - exp - bc, 0)
-    fixdps = int(fixprec / math.log(10,2) + 0.5)
+    fixdps = int(fixprec / blog2 + 0.5)
     sf = to_fixed(s, fixprec)
-    sd = bin_to_radix(sf, fixprec, 10, fixdps)
-    digits = numeral(sd, base=10, size=dps)
+    sb = bin_to_radix(sf, fixprec, base, fixdps)
+    digits = numeral(sb, base=base, size=dps)
 
     exponent += len(digits) - fixdps - 1
     return sign, digits, exponent
 
 def to_str(s, dps, strip_zeros=True, min_fixed=None, max_fixed=None,
-    show_zero_exponent=False):
+           show_zero_exponent=False, base=10, binary_exp=False):
     """
-    Convert a raw mpf to a decimal floating-point literal with at
-    most `dps` decimal digits in the mantissa (not counting extra zeros
+    Convert a raw mpf to a floating-point literal in the given base
+    with at most `dps` digits in the mantissa (not counting extra zeros
     that may be inserted for visual purposes).
 
     The number will be printed in fixed-point format if the position
@@ -1092,9 +1099,30 @@ def to_str(s, dps, strip_zeros=True, min_fixed=None, max_fixed=None,
     max_fixed = +inf. To force floating-point format, set
     min_fixed >= max_fixed.
 
+    If binary_exp is True and the base is either 2 or 16, the number will
+    be printed in a binary or hexadecimal notation, where the exponent
+    separator is the 'p' and the exponent is written in decimal rather than
+    hexadecimal or binary.  The number is normalized, i.e. the first
+    digit is 1.  This is format of the float.fromhex().
+
     The literal is formatted so that it can be parsed back to a number
-    by from_str, float() or Decimal().
+    by from_str, float(), float.fromhex() or Decimal().
     """
+    sep = '@' if base > 10 else 'e'
+
+    if binary_exp:
+        sep = 'p'
+        if base not in (2, 16):
+            raise ValueError("binary_exp option could be used for base 2 and 16")
+
+    if base == 2:
+        prefix = "0b"
+    elif base == 8:
+        prefix = "0o"
+    elif base == 16:
+        prefix = "0x"
+    else:
+        prefix = ""
 
     # Special numbers
     if not s[1]:
@@ -1102,8 +1130,8 @@ def to_str(s, dps, strip_zeros=True, min_fixed=None, max_fixed=None,
             if dps: t = '0.0'
             else:   t = '.0'
             if show_zero_exponent:
-                t += 'e+0'
-            return t
+                t += sep + '+0'
+            return prefix + t
         if s == finf: return '+inf'
         if s == fninf: return '-inf'
         if s == fnan: return 'nan'
@@ -1114,23 +1142,36 @@ def to_str(s, dps, strip_zeros=True, min_fixed=None, max_fixed=None,
 
     # to_digits_exp rounds to floor.
     # This sometimes kills some instances of "...00001"
-    sign, digits, exponent = to_digits_exp(s, dps+3)
+    sign, digits, exponent = to_digits_exp(s, dps+3, base)
+
+    rnd_digs = stddigits[(base//2 + base%2):base]
 
     # No digits: show only .0; round exponent to nearest
     if not dps:
-        if digits[0] in '56789':
+        if digits[0] in rnd_digs:
             exponent += 1
         digits = ".0"
 
     else:
+        if binary_exp and base == 16:
+            exponent *= 4
+            # 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:]
+
         # Rounding up kills some instances of "...99999"
-        if len(digits) > dps and digits[dps] in '56789':
+        if len(digits) > dps and digits[dps] in rnd_digs:
             digits = digits[:dps]
             i = dps - 1
-            while i >= 0 and digits[i] == '9':
+            dig = stddigits[base-1]
+            while i >= 0 and digits[i] == dig:
                 i -= 1
             if i >= 0:
-                digits = digits[:i] + str(int(digits[i]) + 1) + '0' * (dps - i - 1)
+                digits = digits[:i] + stddigits[int(digits[i], base) + 1] + \
+                    '0' * (dps - i - 1)
             else:
                 digits = '1' + '0' * (dps - 1)
                 exponent += 1
@@ -1138,7 +1179,7 @@ def to_str(s, dps, strip_zeros=True, min_fixed=None, max_fixed=None,
             digits = digits[:dps]
 
         # Prettify numbers close to unit magnitude
-        if min_fixed < exponent < max_fixed:
+        if not binary_exp and min_fixed < exponent < max_fixed:
             if exponent < 0:
                 digits = ("0"*(-exponent)) + digits
                 split = 1
@@ -1158,9 +1199,10 @@ def to_str(s, dps, strip_zeros=True, min_fixed=None, max_fixed=None,
             if digits[-1] == ".":
                 digits += "0"
 
+    sign += prefix
+
     if exponent == 0 and dps and not show_zero_exponent: return sign + digits
-    if exponent >= 0: return sign + digits + "e+" + str(exponent)
-    if exponent < 0: return sign + digits + "e" + str(exponent)
+    return sign + digits + sep + f"{exponent:+}"
 
 def str_to_man_exp(x, base=10):
     """Helper function for from_str."""


Test patch:
diff --git a/mpmath/tests/test_basic_ops.py b/mpmath/tests/test_basic_ops.py
index 13fdc38..5f15fef 100644
--- a/mpmath/tests/test_basic_ops.py
+++ b/mpmath/tests/test_basic_ops.py
@@ -155,6 +155,9 @@ def _mpf_(self):
     assert mpf(mympf()) == mpf(3.5)
     assert mympf() - mpf(0.5) == mpf(3.0)
     assert mpf(decimal.Decimal('1.5')) == mpf('1.5')
+    assert mpf('0x1.4ace478p+33') == mpf(11100000000.0)
+    assert mpf('0x1.4ace478p+33', base=0) == mpf(11100000000.0)
+    assert mpf('1.4ace478p+33', base=16) == mpf(11100000000.0)
 
 def test_mpc_init():
     class mympc:
diff --git a/mpmath/tests/test_convert.py b/mpmath/tests/test_convert.py
index 567b5be..6c09398 100644
--- a/mpmath/tests/test_convert.py
+++ b/mpmath/tests/test_convert.py
@@ -65,6 +65,19 @@ def test_eps_repr():
     mp.dps = 24
     assert repr(mp.eps) == '<epsilon of working precision: 2.06795e-25~>'
 
+def test_to_str():
+    assert to_str(from_str('ABC.ABC', base=16), 6, base=16) == '0xabc.abc'
+    assert to_str(from_str('0x3.a7p10', base=16), 3, base=16) == '0xe9c.0'
+    assert to_str(from_str('0x1.4ace478p+33'), 7, base=16) == '0x2.959c8f@+8'
+    assert to_str(from_str('0o1101.100101'), 8, base=8) == '0o1101.1001'
+    assert to_str(from_str('0b1101.100101'), 10, base=2) == '0b1101.100101'
+    assert to_str(from_str('0x1.4ace478p+33'), 8, base=16, binary_exp=True) == '0x1.4ace478p+33'
+    assert to_str(from_str('0x1.4ace478p+33'), 7, base=16, binary_exp=True) == '0x1.4ace48p+33'
+    assert to_str(from_str('0x1.4ace478p+33'), 5, base=16, binary_exp=True) == '0x1.4acep+33'
+    assert to_str(from_str('1', base=16), 6, base=16, binary_exp=True) == '0x1.0'
+    pytest.raises(ValueError, lambda: to_str(from_str('1', base=16),
+                                             6, binary_exp=True))
+
 def test_pretty():
     mp.pretty = True
     assert repr(mpf(2.5)) == '2.5'