Skip to content

bpo-37836: support .as_integer_ratio() in Fraction #15327

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 21 additions & 16 deletions Doc/library/fractions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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::

Expand Down Expand Up @@ -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)
Expand All @@ -82,7 +82,12 @@ another rational number, 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()`` (in particular also :class:`float` and
:class:`decimal.Decimal` instances) as numerator or denominator.


.. attribute:: numerator
Expand Down
83 changes: 50 additions & 33 deletions Lib/fractions.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,25 +57,43 @@ 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.
"""
try:
f = obj.as_integer_ratio
except AttributeError:
if isinstance(obj, numbers.Rational):
return (obj.numerator, obj.denominator)
else:
return f()

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. The numerator and denominator can be:

- objects with an ``as_integer_ratio()`` method (this includes
integers, Fractions, floats and Decimal instances)

Fractions can also be constructed from:
- other Rational instances

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'

- float and Decimal instances

- other Rational instances (including integers)

"""

__slots__ = ('_numerator', '_denominator')
Expand Down Expand Up @@ -115,23 +133,19 @@ 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

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' %
Expand All @@ -158,21 +172,24 @@ def __new__(cls, numerator=0, denominator=None, *, _normalize=True):
numerator = -numerator

else:
raise TypeError("argument should be a string "
"or a Rational instance")
raise TypeError("argument should be a string, "
"a Rational instance or have "
"an as_integer_ratio() method")

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
)
# Fast path for Fraction(int, int)
pass
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)
Expand Down
21 changes: 21 additions & 0 deletions Lib/test/test_fractions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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)))
Expand All @@ -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),
Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +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.