Repository: mpmath/mpmath (python)

Issue:
rounding in Python 3 need not return a `float`
This is based on a [discussion in SymPy](https://github.com/sympy/sympy/issues/12056)

I think `round` should return an mpf rather then a `float`.  Consider:
```python
>>> mp.dps = 64
>>> x = mpmath.mpf('0.66666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666')

>>>  round(x, 48)      # is not going to give 48 digits
0.6666666666666666

>>> repr(_)            # its a float
'0.6666666666666666'
```
The current implementation of `__round__` just converts to `float` and rounds there.  I think we can do better---and are allowed to in Python 3.

Pull request description:
mpf.__round__() returns mpf
Probably this will look non-intuitive, but underlying floating-point arithmetic is binary, not decimal.  Thus, round(x, n) sometimes will pick up a decimal representation with more than n digits.  This is something common for CPython < 2.7 and < 3.1:
```pycon
Python 2.6.6 (r266:84292, Aug 12 2014, 07:57:07) [GCC 4.4.5] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> round(0.42754287856598971, 2)
0.42999999999999999
```
And with this patch as well:
```pycon
Python 3.12.5+ (heads/3.12:0181aa2e3e, Aug 29 2024, 14:55:08) [GCC 12.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from mpmath import *
>>> round(mp.mpf(0.42754287856598971), 2)
mpf('0.42999999999999999')
>>> mp.pretty = True
>>> round(mp.mpf(0.42754287856598971), 2)
0.43
```

See also https://github.com/python/cpython/issues/45921

Closes #455

Patch:
diff --git a/mpmath/ctx_mp_python.py b/mpmath/ctx_mp_python.py
index 62a1f9a..fbd9085 100644
--- a/mpmath/ctx_mp_python.py
+++ b/mpmath/ctx_mp_python.py
@@ -412,8 +412,13 @@ def ae(s, t, rel_eps=None, abs_eps=None):
     def to_fixed(self, prec):
         return to_fixed(self._mpf_, prec)
 
-    def __round__(self, *args):
-        return round(float(self), *args)
+    def __round__(self, ndigits=0):
+        ctx = self.context
+        if ctx.isfinite(self):
+            frac = MPQ(*self.as_integer_ratio())
+            res = round(frac, ndigits)
+            return ctx.convert(res)
+        return self
 
 
 class _constant(_mpf):


Test patch:
diff --git a/mpmath/tests/test_basic_ops.py b/mpmath/tests/test_basic_ops.py
index 119b3fc..5fae4c4 100644
--- a/mpmath/tests/test_basic_ops.py
+++ b/mpmath/tests/test_basic_ops.py
@@ -1,13 +1,16 @@
 import decimal
+import math
 import operator
 import random
 
 import pytest
+from hypothesis import example, given, settings
+from hypothesis import strategies as st
 
 import mpmath
 from mpmath import (ceil, fadd, fdiv, floor, fmul, fneg, fp, frac, fsub, inf,
                     isinf, isint, isnan, isnormal, iv, monitor, mp, mpc, mpf,
-                    mpi, nan, ninf, nint, nint_distance, pi, workprec)
+                    mpi, nan, ninf, nint, nint_distance, nstr, pi, workprec)
 from mpmath.libmp import (MPQ, finf, fnan, fninf, fnone, fone, from_float,
                           from_int, from_pickable, from_str, mpf_add, mpf_mul,
                           mpf_sub, round_down, round_nearest, round_up, to_int,
@@ -607,3 +610,32 @@ def get_remainder(x, bits):
 
 def test_issue_260():
     assert mpc(str(mpc(1j))) == mpc(1j)
+
+
+@settings(max_examples=10000)
+@given(st.floats(allow_nan=True,
+                 allow_infinity=True,
+                 allow_subnormal=True),
+       st.integers(min_value=0, max_value=15))
+@example(0.5, 0)
+@example(-0.5, 0)
+@example(1.5, 0)
+@example(-1.5, 0)
+@example(2.675, 2)
+@example(math.inf, 3)
+@example(-math.inf, 1)
+def test_round_bulk(x, n):
+    mp.prec = fp.prec
+    m = mpf(x)
+    mr = round(m, n)
+    xr = round(x, n)
+    if isnan(x):
+        assert isnan(mr)
+        assert isnan(xr)
+    else:
+        assert float(mr) == xr
+        # mp context doesn't support negative zero
+        if not xr and math.copysign(1., xr) == -1.:
+            return
+        assert nstr(mr, n=14, base=16, strip_zeros=False,
+                    show_zero_exponent=True, binary_exp=True) == xr.hex()