Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
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
10 changes: 7 additions & 3 deletions Doc/whatsnew/3.14.rst
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,13 @@ ast
fractions
---------

Added support for converting any objects that have the
:meth:`!as_integer_ratio` method to a :class:`~fractions.Fraction`.
(Contributed by Serhiy Storchaka in :gh:`82017`.)
* Added support for converting any objects that have the
: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`.)

json
----
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):
Copy link
Contributor

@skirpichev skirpichev Oct 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, you can reuse this in the RationalFraction constructor, isn't? First three if/elif's share exactly same logic.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean the main Fraction constructor?

There is a different exception if the argument type is not supported. There is also a matter of performance.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant some helper:

@classmethod
def _from_number(cls, number):
    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())

Then you can reuse one in the constructor and in the from_number(), e.g.:

        if denominator is None:
            self = cls._from_number(numerator)
            if self is not None:
                return self

            elif isinstance(numerator, str):            
                ...

There is also a matter of performance.

I don't expect too much from extra class method call.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extra function call and extra check.

This will not save much code. I'll leave this on future if we touch this code again.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, it's up to you. PR has Mark approval, so it's ready to go.

"""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>`.