Skip to content

Commit

Permalink
gh-121797: Add class method Fraction.from_number() (GH-121800)
Browse files Browse the repository at this point in the history
It is an alternative constructor which only accepts a single numeric argument.
Unlike to Fraction.from_float() and Fraction.from_decimal() it accepts any
real numbers supported by the standard constructor (int, float, Decimal,
Rational numbers, objects with as_integer_ratio()).
Unlike to the standard constructor, it does not accept strings.
  • Loading branch information
serhiy-storchaka authored Oct 14, 2024
1 parent 66b3922 commit b52c730
Show file tree
Hide file tree
Showing 5 changed files with 82 additions and 8 deletions.
10 changes: 10 additions & 0 deletions Doc/library/fractions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,16 @@ another rational number, or from a string.
instance.


.. classmethod:: from_number(number)

Alternative constructor which only accepts instances of
:class:`numbers.Integral`, :class:`numbers.Rational`,
:class:`float` or :class:`decimal.Decimal`, and objects with
the :meth:`!as_integer_ratio` method, but not strings.

.. versionadded:: 3.14


.. method:: limit_denominator(max_denominator=1000000)

Finds and returns the closest :class:`Fraction` to ``self`` that has
Expand Down
4 changes: 4 additions & 0 deletions Doc/whatsnew/3.14.rst
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,10 @@ fractions
:meth:`!as_integer_ratio` method to a :class:`~fractions.Fraction`.
(Contributed by Serhiy Storchaka in :gh:`82017`.)

* Add alternative :class:`~fractions.Fraction` constructor
:meth:`Fraction.from_number() <fractions.Fraction.from_number>`.
(Contributed by Serhiy Storchaka in :gh:`121797`.)


functools
---------
Expand Down
25 changes: 24 additions & 1 deletion Lib/fractions.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,8 @@ def __new__(cls, numerator=0, denominator=None):
numerator = -numerator

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

elif type(numerator) is int is type(denominator):
pass # *very* normal case
Expand All @@ -305,6 +306,28 @@ def __new__(cls, numerator=0, denominator=None):
self._denominator = denominator
return self

@classmethod
def from_number(cls, number):
"""Converts a finite real number to a rational number, exactly.
Beware that Fraction.from_number(0.3) != Fraction(3, 10).
"""
if type(number) is int:
return cls._from_coprime_ints(number, 1)

elif isinstance(number, numbers.Rational):
return cls._from_coprime_ints(number.numerator, number.denominator)

elif (isinstance(number, float) or
(not isinstance(number, type) and
hasattr(number, 'as_integer_ratio'))):
return cls._from_coprime_ints(*number.as_integer_ratio())

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

@classmethod
def from_float(cls, f):
"""Converts a finite float to a rational number, exactly.
Expand Down
49 changes: 42 additions & 7 deletions Lib/test/test_fractions.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,13 @@ def __repr__(self):
class RectComplex(Rect, complex):
pass

class Ratio:
def __init__(self, ratio):
self._ratio = ratio
def as_integer_ratio(self):
return self._ratio


class FractionTest(unittest.TestCase):

def assertTypedEquals(self, expected, actual):
Expand Down Expand Up @@ -355,14 +362,9 @@ def testInitFromDecimal(self):
self.assertRaises(OverflowError, F, Decimal('-inf'))

def testInitFromIntegerRatio(self):
class Ratio:
def __init__(self, ratio):
self._ratio = ratio
def as_integer_ratio(self):
return self._ratio

self.assertEqual((7, 3), _components(F(Ratio((7, 3)))))
errmsg = "argument should be a string or a number"
errmsg = (r"argument should be a string or a Rational instance or "
r"have the as_integer_ratio\(\) method")
# the type also has an "as_integer_ratio" attribute.
self.assertRaisesRegex(TypeError, errmsg, F, Ratio)
# bad ratio
Expand All @@ -388,6 +390,8 @@ class B(metaclass=M):
pass
self.assertRaisesRegex(TypeError, errmsg, F, B)
self.assertRaisesRegex(TypeError, errmsg, F, B())
self.assertRaises(TypeError, F.from_number, B)
self.assertRaises(TypeError, F.from_number, B())

def testFromString(self):
self.assertEqual((5, 1), _components(F("5")))
Expand Down Expand Up @@ -594,6 +598,37 @@ def testFromDecimal(self):
ValueError, "cannot convert NaN to integer ratio",
F.from_decimal, Decimal("snan"))

def testFromNumber(self, cls=F):
def check(arg, numerator, denominator):
f = cls.from_number(arg)
self.assertIs(type(f), cls)
self.assertEqual(f.numerator, numerator)
self.assertEqual(f.denominator, denominator)

check(10, 10, 1)
check(2.5, 5, 2)
check(Decimal('2.5'), 5, 2)
check(F(22, 7), 22, 7)
check(DummyFraction(22, 7), 22, 7)
check(Rat(22, 7), 22, 7)
check(Ratio((22, 7)), 22, 7)
self.assertRaises(TypeError, cls.from_number, 3+4j)
self.assertRaises(TypeError, cls.from_number, '5/2')
self.assertRaises(TypeError, cls.from_number, [])
self.assertRaises(OverflowError, cls.from_number, float('inf'))
self.assertRaises(OverflowError, cls.from_number, Decimal('inf'))

# as_integer_ratio not defined in a class
class A:
pass
a = A()
a.as_integer_ratio = lambda: (9, 5)
check(a, 9, 5)

def testFromNumber_subclass(self):
self.testFromNumber(DummyFraction)


def test_is_integer(self):
self.assertTrue(F(1, 1).is_integer())
self.assertTrue(F(-1, 1).is_integer())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Add alternative :class:`~fractions.Fraction` constructor
:meth:`Fraction.from_number() <fractions.Fraction.from_number>`.

0 comments on commit b52c730

Please sign in to comment.