From 2fe964f369943aff5bb62c73836ca61707c2d4ff Mon Sep 17 00:00:00 2001 From: Jeroen Demeyer Date: Sun, 18 Aug 2019 20:52:20 +0200 Subject: [PATCH 1/4] bpo-37836: support .as_integer_ratio() in Fraction When constructing instances of fractions.Fraction, the given numerator and denominator may now be any objects with an as_integer_ratio() method. --- Doc/library/fractions.rst | 34 ++++---- Lib/fractions.py | 85 +++++++++++-------- Lib/test/test_fractions.py | 21 +++++ .../2019-08-18-22-29-59.bpo-37836.BbvvFm.rst | 3 + 4 files changed, 93 insertions(+), 50 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2019-08-18-22-29-59.bpo-37836.BbvvFm.rst diff --git a/Doc/library/fractions.rst b/Doc/library/fractions.rst index 58e7126b0bf212..eadf108b3531c1 100644 --- a/Doc/library/fractions.rst +++ b/Doc/library/fractions.rst @@ -14,27 +14,25 @@ The :mod:`fractions` module provides support for rational number arithmetic. -A Fraction instance can be constructed from a pair of integers, from -another rational number, or from a string. +A Fraction instance can be constructed from a numerator and denominator +or from a string. .. class:: Fraction(numerator=0, denominator=1) - Fraction(other_fraction) - Fraction(float) - Fraction(decimal) Fraction(string) - The first version requires that *numerator* and *denominator* are instances - of :class:`numbers.Rational` and returns a new :class:`Fraction` instance - with value ``numerator/denominator``. If *denominator* is :const:`0`, it - raises a :exc:`ZeroDivisionError`. The second version requires that - *other_fraction* is an instance of :class:`numbers.Rational` and returns a - :class:`Fraction` instance with the same value. The next two versions accept - either a :class:`float` or a :class:`decimal.Decimal` instance, and return a - :class:`Fraction` instance with exactly the same value. Note that due to the - usual issues with binary floating-point (see :ref:`tut-fp-issues`), the - argument to ``Fraction(1.1)`` is not exactly equal to 11/10, and so + In the first version, *numerator* and *denominator* must either have + an ``as_integer_ratio()`` method or be instances of + :class:`numbers.Rational`. This includes in particular :class:`int`, + :class:`Fraction`, :class:`float` and :class:`decimal.Decimal`. + It returns a new :class:`Fraction` instance with value + ``numerator/denominator``. If *denominator* is :const:`0`, it raises a + :exc:`ZeroDivisionError`. + + Note that due to the usual issues with binary floating-point + (see :ref:`tut-fp-issues`), 1.1 is not exactly equal to 11/10, and so ``Fraction(1.1)`` does *not* return ``Fraction(11, 10)`` as one might expect. (But see the documentation for the :meth:`limit_denominator` method below.) + The last version of the constructor expects a string or unicode instance. The usual form for this instance is:: @@ -69,6 +67,8 @@ another rational number, or from a string. Fraction(9, 4) >>> Fraction(1.1) Fraction(2476979795053773, 2251799813685248) + >>> Fraction(3.5, 2.5) + Fraction(7, 5) >>> from decimal import Decimal >>> Fraction(Decimal('1.1')) Fraction(11, 10) @@ -84,6 +84,10 @@ another rational number, or from a string. The :class:`Fraction` constructor now accepts :class:`float` and :class:`decimal.Decimal` instances. + .. versionchanged:: 3.9 + The :class:`Fraction` constructor now accepts any object with + ``as_integer_ratio()`` as numerator or denominator. + .. attribute:: numerator diff --git a/Lib/fractions.py b/Lib/fractions.py index 2e7047a81844d2..40b3d299ce2dfc 100644 --- a/Lib/fractions.py +++ b/Lib/fractions.py @@ -57,22 +57,46 @@ def _gcd(a, b): """, re.VERBOSE | re.IGNORECASE) +def _as_integer_ratio(obj): + """ + Return a pair (numerator, denominator) if obj has an ``as_integer_ratio`` + method or is an instance of ``numbers.Rational``. Return ``NotImplemented`` + if neither works. + """ + # Fast path + if type(obj) is int: + return (obj, 1) + + try: + f = obj.as_integer_ratio + except AttributeError: + pass + else: + return f() + + if isinstance(obj, numbers.Rational): + return (obj.numerator, obj.denominator) + + return NotImplemented + + class Fraction(numbers.Rational): """This class implements rational numbers. In the two-argument form of the constructor, Fraction(8, 6) will - produce a rational number equivalent to 4/3. Both arguments must - be Rational. The numerator defaults to 0 and the denominator - defaults to 1 so that Fraction(3) == 3 and Fraction() == 0. + produce a rational number equivalent to 4/3. The numerator defaults + to 0 and the denominator defaults to 1 so that Fraction(3) == 3 and + Fraction() == 0. - Fractions can also be constructed from: + Fractions can be constructed from: - numeric strings similar to those accepted by the float constructor (for example, '-2.3' or '1e10') - strings of the form '123/456' - - float and Decimal instances + - objects with an ``as_integer_ratio()`` method (this includes + float and Decimal instances) - other Rational instances (including integers) @@ -115,23 +139,14 @@ def __new__(cls, numerator=0, denominator=None, *, _normalize=True): self = super(Fraction, cls).__new__(cls) if denominator is None: - if type(numerator) is int: - self._numerator = numerator - self._denominator = 1 - return self - - elif isinstance(numerator, numbers.Rational): - self._numerator = numerator.numerator - self._denominator = numerator.denominator - return self - - elif isinstance(numerator, (float, Decimal)): - # Exact conversion - self._numerator, self._denominator = numerator.as_integer_ratio() - return self - + nd = _as_integer_ratio(numerator) + if nd is not NotImplemented: + numerator, denominator = nd + # Assume that result is already normalized + _normalize = False + # _as_integer_ratio() returned NotImplemented, so we + # failed unless we got a string elif isinstance(numerator, str): - # Handle construction from strings. m = _RATIONAL_FORMAT.match(numerator) if m is None: raise ValueError('Invalid literal for Fraction: %r' % @@ -158,21 +173,21 @@ def __new__(cls, numerator=0, denominator=None, *, _normalize=True): numerator = -numerator else: - raise TypeError("argument should be a string " - "or a Rational instance") - - elif type(numerator) is int is type(denominator): - pass # *very* normal case - - elif (isinstance(numerator, numbers.Rational) and - isinstance(denominator, numbers.Rational)): - numerator, denominator = ( - numerator.numerator * denominator.denominator, - denominator.numerator * numerator.denominator - ) + raise TypeError("argument should be a string, " + "a Rational instance or have " + "an as_integer_ratio() method") + else: - raise TypeError("both arguments should be " - "Rational instances") + x = _as_integer_ratio(numerator) + y = _as_integer_ratio(denominator) + if x is NotImplemented or y is NotImplemented: + raise TypeError("both arguments should be " + "a Rational instance or have " + "an as_integer_ratio() method") + num1, den1 = x + den2, num2 = y + numerator = num1 * num2 + denominator = den1 * den2 if denominator == 0: raise ZeroDivisionError('Fraction(%s, 0)' % numerator) diff --git a/Lib/test/test_fractions.py b/Lib/test/test_fractions.py index 18ab28cfebe0c8..baf471613e1d86 100644 --- a/Lib/test/test_fractions.py +++ b/Lib/test/test_fractions.py @@ -78,6 +78,15 @@ def __ge__(self, other): def __float__(self): assert False, "__float__ should not be invoked" + +class IntegerRatio: + def __init__(self, num, den): + self.integer_ratio = (num, den) + + def as_integer_ratio(self): + return self.integer_ratio + + class DummyFraction(fractions.Fraction): """Dummy Fraction subclass for copy and deepcopy testing.""" @@ -155,11 +164,13 @@ def testInit(self): self.assertRaises(TypeError, F, "3/2", 3) self.assertRaises(TypeError, F, 3, 0j) self.assertRaises(TypeError, F, 3, 1j) + self.assertRaises(TypeError, F, 1j, 3) self.assertRaises(TypeError, F, 1, 2, 3) @requires_IEEE_754 def testInitFromFloat(self): self.assertEqual((5, 2), _components(F(2.5))) + self.assertEqual((5, 7), _components(F(2.5, 3.5))) self.assertEqual((0, 1), _components(F(-0.0))) self.assertEqual((3602879701896397, 36028797018963968), _components(F(0.1))) @@ -171,6 +182,8 @@ def testInitFromFloat(self): def testInitFromDecimal(self): self.assertEqual((11, 10), _components(F(Decimal('1.1')))) + self.assertEqual((11, 10), + _components(F(Decimal('9.9'), Decimal(9)))) self.assertEqual((7, 200), _components(F(Decimal('3.5e-2')))) self.assertEqual((0, 1), @@ -302,6 +315,14 @@ def testFromDecimal(self): ValueError, "cannot convert NaN to integer ratio", F.from_decimal, Decimal("snan")) + def testFromIntegerRatio(self): + a = IntegerRatio(-5, 4) + b = IntegerRatio(-5, 3) + self.assertEqual(F(a).as_integer_ratio(), (-5, 4)) + self.assertEqual(F(a, b).as_integer_ratio(), (3, 4)) + self.assertEqual(F(1, a).as_integer_ratio(), (-4, 5)) + self.assertEqual(F(a, 5.0).as_integer_ratio(), (-1, 4)) + def test_as_integer_ratio(self): self.assertEqual(F(4, 6).as_integer_ratio(), (2, 3)) self.assertEqual(F(-4, 6).as_integer_ratio(), (-2, 3)) diff --git a/Misc/NEWS.d/next/Library/2019-08-18-22-29-59.bpo-37836.BbvvFm.rst b/Misc/NEWS.d/next/Library/2019-08-18-22-29-59.bpo-37836.BbvvFm.rst new file mode 100644 index 00000000000000..149c8d2edd9935 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2019-08-18-22-29-59.bpo-37836.BbvvFm.rst @@ -0,0 +1,3 @@ +When constructing a :class:`fractions.Fraction`, the given numerator (and +optional denominator) may now be any object with an ``as_integer_ratio`` +method. From f1b1e8cf06c9e1cd6607e8ec94fd3c758f648928 Mon Sep 17 00:00:00 2001 From: Jeroen Demeyer Date: Mon, 19 Aug 2019 11:00:27 +0200 Subject: [PATCH 2/4] bpo-37836: optimize creating Fraction from integers --- Lib/fractions.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Lib/fractions.py b/Lib/fractions.py index 40b3d299ce2dfc..906c7e2bdc54ca 100644 --- a/Lib/fractions.py +++ b/Lib/fractions.py @@ -63,10 +63,6 @@ def _as_integer_ratio(obj): method or is an instance of ``numbers.Rational``. Return ``NotImplemented`` if neither works. """ - # Fast path - if type(obj) is int: - return (obj, 1) - try: f = obj.as_integer_ratio except AttributeError: @@ -139,6 +135,11 @@ def __new__(cls, numerator=0, denominator=None, *, _normalize=True): self = super(Fraction, cls).__new__(cls) if denominator is None: + # Fast path for Fraction(int) + if type(numerator) is int: + self._numerator = numerator + self._denominator = 1 + return self nd = _as_integer_ratio(numerator) if nd is not NotImplemented: numerator, denominator = nd @@ -177,6 +178,9 @@ def __new__(cls, numerator=0, denominator=None, *, _normalize=True): "a Rational instance or have " "an as_integer_ratio() method") + elif type(numerator) is int is type(denominator): + # Fast path for Fraction(int, int) + pass else: x = _as_integer_ratio(numerator) y = _as_integer_ratio(denominator) From 9c7bdb5e057d990edb07031379efc7edfe19f886 Mon Sep 17 00:00:00 2001 From: Jeroen Demeyer Date: Sun, 25 Aug 2019 10:54:55 +0200 Subject: [PATCH 3/4] bpo-37836: minor fixes --- Doc/library/fractions.rst | 5 +++-- Lib/fractions.py | 22 +++++++++---------- .../2019-08-18-22-29-59.bpo-37836.BbvvFm.rst | 6 ++--- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/Doc/library/fractions.rst b/Doc/library/fractions.rst index eadf108b3531c1..4e9ce9bcfcdf18 100644 --- a/Doc/library/fractions.rst +++ b/Doc/library/fractions.rst @@ -82,11 +82,12 @@ or from a string. .. versionchanged:: 3.2 The :class:`Fraction` constructor now accepts :class:`float` and - :class:`decimal.Decimal` instances. + :class:`decimal.Decimal` instances as a single argument. .. versionchanged:: 3.9 The :class:`Fraction` constructor now accepts any object with - ``as_integer_ratio()`` as numerator or denominator. + ``as_integer_ratio()`` (in particular also :class:`float` and + :class:`decimal.Decimal` instances) as numerator or denominator. .. attribute:: numerator diff --git a/Lib/fractions.py b/Lib/fractions.py index 906c7e2bdc54ca..bf5e8206dc770d 100644 --- a/Lib/fractions.py +++ b/Lib/fractions.py @@ -66,13 +66,11 @@ def _as_integer_ratio(obj): try: f = obj.as_integer_ratio except AttributeError: - pass + if isinstance(obj, numbers.Rational): + return (obj.numerator, obj.denominator) else: return f() - if isinstance(obj, numbers.Rational): - return (obj.numerator, obj.denominator) - return NotImplemented @@ -82,20 +80,20 @@ class Fraction(numbers.Rational): In the two-argument form of the constructor, Fraction(8, 6) will produce a rational number equivalent to 4/3. The numerator defaults to 0 and the denominator defaults to 1 so that Fraction(3) == 3 and - Fraction() == 0. + Fraction() == 0. The numerator and denominator can be: + + - objects with an ``as_integer_ratio()`` method (this includes + integers, Fractions, floats and Decimal instances) + + - other Rational instances - Fractions can be constructed from: + Fractions can also be constructed from a string (in this case, only + a single argument is allowed): - numeric strings similar to those accepted by the float constructor (for example, '-2.3' or '1e10') - strings of the form '123/456' - - - objects with an ``as_integer_ratio()`` method (this includes - float and Decimal instances) - - - other Rational instances (including integers) - """ __slots__ = ('_numerator', '_denominator') diff --git a/Misc/NEWS.d/next/Library/2019-08-18-22-29-59.bpo-37836.BbvvFm.rst b/Misc/NEWS.d/next/Library/2019-08-18-22-29-59.bpo-37836.BbvvFm.rst index 149c8d2edd9935..02ddf02423e2a0 100644 --- a/Misc/NEWS.d/next/Library/2019-08-18-22-29-59.bpo-37836.BbvvFm.rst +++ b/Misc/NEWS.d/next/Library/2019-08-18-22-29-59.bpo-37836.BbvvFm.rst @@ -1,3 +1,3 @@ -When constructing a :class:`fractions.Fraction`, the given numerator (and -optional denominator) may now be any object with an ``as_integer_ratio`` -method. +When constructing a :class:`fractions.Fraction`, the given numerator and +denominator may now be any object with an ``as_integer_ratio`` method. +This allows for example passing floats for both the numerator and denominator. From d2303962827e32c201d5995d411b7c03fed1ab6f Mon Sep 17 00:00:00 2001 From: Jeroen Demeyer Date: Mon, 26 Aug 2019 06:59:59 +0200 Subject: [PATCH 4/4] bpo-37836: detail in NEWS Co-Authored-By: Ashwin Ramaswami --- .../next/Library/2019-08-18-22-29-59.bpo-37836.BbvvFm.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Library/2019-08-18-22-29-59.bpo-37836.BbvvFm.rst b/Misc/NEWS.d/next/Library/2019-08-18-22-29-59.bpo-37836.BbvvFm.rst index 02ddf02423e2a0..2ef6d2863ea387 100644 --- a/Misc/NEWS.d/next/Library/2019-08-18-22-29-59.bpo-37836.BbvvFm.rst +++ b/Misc/NEWS.d/next/Library/2019-08-18-22-29-59.bpo-37836.BbvvFm.rst @@ -1,3 +1,3 @@ When constructing a :class:`fractions.Fraction`, the given numerator and denominator may now be any object with an ``as_integer_ratio`` method. -This allows for example passing floats for both the numerator and denominator. +This allows, for example, passing floats for both the numerator and denominator.