From 4929c3ae37ea298859c03d4fe933d632c2e2db4e Mon Sep 17 00:00:00 2001 From: Chris Novakovic Date: Fri, 17 Mar 2023 21:01:13 +0000 Subject: [PATCH 1/3] gh-102791: allow non-fractional decimal.Decimals to be interpreted as integers Prior to gh-11952, several standard library functions that expected integer arguments would nevertheless silently accept (and truncate) non-integer arguments. This behaviour was deprecated in gh-11952, and removed in gh-15636. However, it may be possible to interpret some non-integer numeric types (such as `decimal.Decimal`s) as integers if they contain no fractional part. Implement `__index__` for `decimal.Decimal`, returning an integer representation of the value if it does not contain a fractional part or raising a `TypeError` if it does. --- Doc/library/decimal.rst | 5 ++++ Lib/_pydecimal.py | 15 ++++++++++ Lib/test/datetimetester.py | 2 +- Lib/test/test_buffer.py | 8 +++++- Lib/test/test_decimal.py | 21 ++++++++++++++ Lib/test/test_math.py | 9 +++--- Misc/ACKS | 1 + ...-03-17-20-59-49.gh-issue-102791.Wlo6X3.rst | 3 ++ Modules/_decimal/_decimal.c | 28 +++++++++++++++++++ Modules/_decimal/tests/deccheck.py | 4 +-- 10 files changed, 87 insertions(+), 9 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2023-03-17-20-59-49.gh-issue-102791.Wlo6X3.rst diff --git a/Doc/library/decimal.rst b/Doc/library/decimal.rst index 6187098a752053..f45406411bf1f5 100644 --- a/Doc/library/decimal.rst +++ b/Doc/library/decimal.rst @@ -410,6 +410,11 @@ Decimal objects compared, sorted, and coerced to another type (such as :class:`float` or :class:`int`). + .. versionchanged:: 3.12 + A :class:`Decimal` instance may be coerced to an integer (i.e. by + :func:`~operator.index`) if it has no fractional part; otherwise, a + :exc:`TypeError` is raised. + There are some small differences between arithmetic on Decimal objects and arithmetic on integers and floats. When the remainder operator ``%`` is applied to Decimal objects, the sign of the result is the sign of the diff --git a/Lib/_pydecimal.py b/Lib/_pydecimal.py index 2692f2fcba45bf..5f670c84d9b4f4 100644 --- a/Lib/_pydecimal.py +++ b/Lib/_pydecimal.py @@ -1640,6 +1640,21 @@ def __int__(self): else: return s*int(self._int[:self._exp] or '0') + def __index__(self): + """ + Converts self to an int, if it is possible to do so with no loss of + precision. + """ + if self._is_special: + if self._isnan(): + raise ValueError("Cannot convert NaN to integer") + elif self._isinfinity(): + raise OverflowError("Cannot convert infinity to integer") + elif self._exp != 0: + raise TypeError("Cannot convert Decimal with fractional part " + "to integer") + return (-1)**self._sign*int(self._int) + __trunc__ = __int__ @property diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 570f803918c1ef..7ce9c938842852 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -5431,7 +5431,7 @@ class Float(float): pass for xx in [10.0, Float(10.9), - decimal.Decimal(10), decimal.Decimal('10.9'), + decimal.Decimal('10.9'), Number(10), Number(10.9), '10']: self.assertRaises(TypeError, datetime, xx, 10, 10, 10, 10, 10, 10) diff --git a/Lib/test/test_buffer.py b/Lib/test/test_buffer.py index 8ac3b7e7eb29d1..279487fb0e44b2 100644 --- a/Lib/test/test_buffer.py +++ b/Lib/test/test_buffer.py @@ -23,6 +23,7 @@ import sys, array, io, os from decimal import Decimal from fractions import Fraction +import re try: from _testbuffer import * @@ -2532,8 +2533,9 @@ def __index__(self): def f(): return 7 + fd = Decimal("-21.1") values = [INT(9), IDX(9), - 2.2+3j, Decimal("-21.1"), 12.2, Fraction(5, 2), + 2.2+3j, Decimal("2"), fd, 12.2, Fraction(5, 2), [1,2,3], {4,5,6}, {7:8}, (), (9,), True, False, None, Ellipsis, b'a', b'abc', bytearray(b'a'), bytearray(b'abc'), @@ -2559,6 +2561,10 @@ def f(): return 7 struct.pack_into(fmt, nd, itemsize, v) except struct.error: struct_err = struct.error + except TypeError as e: + # This can't be represented as an integer: + if v == fd and re.search('[bBhHiIlLqQnN]', fmt): + struct_err = e mv_err = None try: diff --git a/Lib/test/test_decimal.py b/Lib/test/test_decimal.py index 67ccaab40c5edc..295a693a6ee193 100644 --- a/Lib/test/test_decimal.py +++ b/Lib/test/test_decimal.py @@ -2613,6 +2613,27 @@ def test_int(self): self.assertRaises(OverflowError, int, Decimal('inf')) self.assertRaises(OverflowError, int, Decimal('-inf')) + def test_index(self): + Decimal = self.decimal.Decimal + + for x in range(-250, 250): + self.assertEqual(operator.index(Decimal(x)), x) + + self.assertRaises(TypeError, operator.index, Decimal('2.5')) + + HAVE_CONFIG_64 = (C.MAX_PREC > 425000000) + + # Corner cases + int_max = 2**63-1 if HAVE_CONFIG_64 else 2**31-1 + + self.assertEqual(operator.index(Decimal(int_max-1)), int_max-1) + self.assertEqual(operator.index(Decimal(-int_max)), -int_max) + + self.assertRaises(ValueError, operator.index, Decimal('-nan')) + self.assertRaises(ValueError, operator.index, Decimal('snan')) + self.assertRaises(OverflowError, operator.index, Decimal('inf')) + self.assertRaises(OverflowError, operator.index, Decimal('-inf')) + @cpython_only def test_small_ints(self): Decimal = self.decimal.Decimal diff --git a/Lib/test/test_math.py b/Lib/test/test_math.py index 433161c2dd4145..f28cafd29b8a7d 100644 --- a/Lib/test/test_math.py +++ b/Lib/test/test_math.py @@ -534,7 +534,6 @@ def testFactorialNonIntegers(self): self.assertRaises(TypeError, math.factorial, 5.2) self.assertRaises(TypeError, math.factorial, -1.0) self.assertRaises(TypeError, math.factorial, -1e100) - self.assertRaises(TypeError, math.factorial, decimal.Decimal('5')) self.assertRaises(TypeError, math.factorial, decimal.Decimal('5.2')) self.assertRaises(TypeError, math.factorial, "5") @@ -2173,10 +2172,10 @@ def testPerm(self): # Raises TypeError if any argument is non-integer or argument count is # not 1 or 2 self.assertRaises(TypeError, perm, 10, 1.0) - self.assertRaises(TypeError, perm, 10, decimal.Decimal(1.0)) + self.assertRaises(TypeError, perm, 10, decimal.Decimal(1.1)) self.assertRaises(TypeError, perm, 10, "1") self.assertRaises(TypeError, perm, 10.0, 1) - self.assertRaises(TypeError, perm, decimal.Decimal(10.0), 1) + self.assertRaises(TypeError, perm, decimal.Decimal(10.1), 1) self.assertRaises(TypeError, perm, "10", 1) self.assertRaises(TypeError, perm) @@ -2240,10 +2239,10 @@ def testComb(self): # Raises TypeError if any argument is non-integer or argument count is # not 2 self.assertRaises(TypeError, comb, 10, 1.0) - self.assertRaises(TypeError, comb, 10, decimal.Decimal(1.0)) + self.assertRaises(TypeError, comb, 10, decimal.Decimal(1.1)) self.assertRaises(TypeError, comb, 10, "1") self.assertRaises(TypeError, comb, 10.0, 1) - self.assertRaises(TypeError, comb, decimal.Decimal(10.0), 1) + self.assertRaises(TypeError, comb, decimal.Decimal(10.1), 1) self.assertRaises(TypeError, comb, "10", 1) self.assertRaises(TypeError, comb, 10) diff --git a/Misc/ACKS b/Misc/ACKS index 8cf5166a2bb1f4..5f66d47950882f 100644 --- a/Misc/ACKS +++ b/Misc/ACKS @@ -1293,6 +1293,7 @@ Stefan Norberg Tim Northover Joe Norton Neal Norwitz +Chris Novakovic Mikhail Novikov Michal Nowikowski Steffen Daode Nurpmeso diff --git a/Misc/NEWS.d/next/Library/2023-03-17-20-59-49.gh-issue-102791.Wlo6X3.rst b/Misc/NEWS.d/next/Library/2023-03-17-20-59-49.gh-issue-102791.Wlo6X3.rst new file mode 100644 index 00000000000000..b9f903416eb36e --- /dev/null +++ b/Misc/NEWS.d/next/Library/2023-03-17-20-59-49.gh-issue-102791.Wlo6X3.rst @@ -0,0 +1,3 @@ +:func:`decimal.Decimal` instances may now be interpreted as integers (i.e. +by :func:`~operator.index`) if they have no fractional part. Patch by Chris +Novakovic. diff --git a/Modules/_decimal/_decimal.c b/Modules/_decimal/_decimal.c index 5936fbaaf35eb0..baa3e7045903f8 100644 --- a/Modules/_decimal/_decimal.c +++ b/Modules/_decimal/_decimal.c @@ -3773,6 +3773,33 @@ PyDec_AsFloat(PyObject *dec) return f; } +static PyObject * +PyDec_AsInt(PyObject *dec) +{ + PyObject *context; + + if (mpd_isspecial(MPD(dec))) { + if (mpd_isnan(MPD(dec))) { + PyErr_SetString(PyExc_ValueError, + "NaN cannot be interpreted as an integer"); + } + else { + PyErr_SetString(PyExc_OverflowError, + "Infinity cannot be interpreted as an integer"); + } + return NULL; + } + + if (!mpd_isinteger(MPD(dec))) { + PyErr_SetString(PyExc_TypeError, + "Decimal with fractional part cannot be interpreted as an integer"); + return NULL; + } + + CURRENT_CONTEXT(context); + return dec_as_long(dec, context, MPD_ROUND_TRUNC); +} + static PyObject * PyDec_Round(PyObject *dec, PyObject *args) { @@ -4877,6 +4904,7 @@ static PyNumberMethods dec_number_methods = (binaryfunc) nm_mpd_qdiv, /* binaryfunc nb_true_divide; */ 0, /* binaryfunc nb_inplace_floor_divide; */ 0, /* binaryfunc nb_inplace_true_divide; */ + (unaryfunc) PyDec_AsInt, /* unaryfunc nb_index; */ }; static PyMethodDef dec_methods [] = diff --git a/Modules/_decimal/tests/deccheck.py b/Modules/_decimal/tests/deccheck.py index edf753f3704a18..bc39407e6a2c1f 100644 --- a/Modules/_decimal/tests/deccheck.py +++ b/Modules/_decimal/tests/deccheck.py @@ -63,8 +63,8 @@ # Plain unary: 'unary': ( '__abs__', '__bool__', '__ceil__', '__complex__', '__copy__', - '__floor__', '__float__', '__hash__', '__int__', '__neg__', - '__pos__', '__reduce__', '__repr__', '__str__', '__trunc__', + '__floor__', '__float__', '__hash__', '__index__', '__int__', + '__neg__', '__pos__', '__reduce__', '__repr__', '__str__', '__trunc__', 'adjusted', 'as_integer_ratio', 'as_tuple', 'canonical', 'conjugate', 'copy_abs', 'copy_negate', 'is_canonical', 'is_finite', 'is_infinite', 'is_nan', 'is_qnan', 'is_signed', 'is_snan', 'is_zero', 'radix' From 6752e54a08947e96b9ba4fce3d9012284fd4774d Mon Sep 17 00:00:00 2001 From: Chris Novakovic Date: Fri, 17 Mar 2023 21:20:50 +0000 Subject: [PATCH 2/3] Simplify language in documentation: "coerced to" -> "interpreted as" --- Doc/library/decimal.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/library/decimal.rst b/Doc/library/decimal.rst index f45406411bf1f5..e8dbdde2852516 100644 --- a/Doc/library/decimal.rst +++ b/Doc/library/decimal.rst @@ -411,7 +411,7 @@ Decimal objects :class:`int`). .. versionchanged:: 3.12 - A :class:`Decimal` instance may be coerced to an integer (i.e. by + A :class:`Decimal` instance may be interpreted as an integer (i.e. by :func:`~operator.index`) if it has no fractional part; otherwise, a :exc:`TypeError` is raised. From ca9c85b5027e1845f40824ef38f67f4d49a62a20 Mon Sep 17 00:00:00 2001 From: Chris Novakovic Date: Fri, 17 Mar 2023 21:46:18 +0000 Subject: [PATCH 3/3] Correct `os` test --- Lib/test/test_os.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_os.py b/Lib/test/test_os.py index 74ece3ffb4ed17..ba84a6ed3f251c 100644 --- a/Lib/test/test_os.py +++ b/Lib/test/test_os.py @@ -1743,7 +1743,7 @@ def test_chown_uid_gid_arguments_must_be_index(self): stat = os.stat(os_helper.TESTFN) uid = stat.st_uid gid = stat.st_gid - for value in (-1.0, -1j, decimal.Decimal(-1), fractions.Fraction(-2, 2)): + for value in (-1.0, -1j, decimal.Decimal(-1.1), fractions.Fraction(-2, 2)): self.assertRaises(TypeError, os.chown, os_helper.TESTFN, value, gid) self.assertRaises(TypeError, os.chown, os_helper.TESTFN, uid, value) self.assertIsNone(os.chown(os_helper.TESTFN, uid, gid))