From 62bb662a247f3c1f02d24894afed666e3d47775a Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 8 Jun 2024 16:23:24 +0300 Subject: [PATCH 1/7] gh-82017: Support as_integer_ratio() in the Fraction constructor Any numbers that have the as_integer_ratio() method (e.g. numpy.float128) can now be converted to a fraction. --- Doc/library/fractions.rst | 20 ++++++++-------- Doc/whatsnew/3.14.rst | 7 ++++++ Lib/fractions.py | 4 ++-- Lib/test/test_fractions.py | 23 +++++++++++++++++++ ...4-06-08-17-41-11.gh-issue-82017.WpSTGi.rst | 2 ++ 5 files changed, 45 insertions(+), 11 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2024-06-08-17-41-11.gh-issue-82017.WpSTGi.rst diff --git a/Doc/library/fractions.rst b/Doc/library/fractions.rst index 552d6030b1ceda..e0f42197989c1d 100644 --- a/Doc/library/fractions.rst +++ b/Doc/library/fractions.rst @@ -18,24 +18,26 @@ A Fraction instance can be constructed from a pair of integers, from another rational number, or from a string. .. class:: Fraction(numerator=0, denominator=1) - Fraction(other_fraction) - Fraction(float) - Fraction(decimal) + Fraction(number) 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 ``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 + raises a :exc:`ZeroDivisionError`. + + The second version requires that + *number* is an instance of :class:`numbers.Rational` or is an instance + of :class:`numbers.Number` and has the :meth:`~as_integer_ratio` method + (this includes :class:`float` and :class:`decimal.Decimal`). + It returns 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 ``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 last version of the constructor expects a string. The usual form for this instance is:: [sign] numerator ['/' denominator] diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst index b2dd80b64a691a..ed4c32a1a9130c 100644 --- a/Doc/whatsnew/3.14.rst +++ b/Doc/whatsnew/3.14.rst @@ -92,6 +92,13 @@ ast Added :func:`ast.compare` for comparing two ASTs. (Contributed by Batuhan Taskaya and Jeremy Hylton in :issue:`15987`.) +fractions +--------- + +Added support for converting any numbers that have the +:meth:`!as_integer_ratio` method to a :class:`~fractions.Fraction`. +(Contributed by Serhiy Storchaka in :gh:`82017`.) + Optimizations diff --git a/Lib/fractions.py b/Lib/fractions.py index 565503911bbe97..c735eaf17438f0 100644 --- a/Lib/fractions.py +++ b/Lib/fractions.py @@ -3,7 +3,6 @@ """Fraction, infinite-precision, rational numbers.""" -from decimal import Decimal import functools import math import numbers @@ -244,7 +243,8 @@ def __new__(cls, numerator=0, denominator=None): self._denominator = numerator.denominator return self - elif isinstance(numerator, (float, Decimal)): + elif (isinstance(numerator, numbers.Number) and + hasattr(numerator, 'as_integer_ratio')): # Exact conversion self._numerator, self._denominator = numerator.as_integer_ratio() return self diff --git a/Lib/test/test_fractions.py b/Lib/test/test_fractions.py index 71865f68eb0f12..47e64223a01981 100644 --- a/Lib/test/test_fractions.py +++ b/Lib/test/test_fractions.py @@ -355,6 +355,29 @@ def testInitFromDecimal(self): self.assertRaises(OverflowError, F, Decimal('inf')) 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 + class RatioNumber(Ratio): + pass + numbers.Number.register(RatioNumber) + + self.assertEqual((7, 3), _components(F(RatioNumber((7, 3))))) + # not a number + self.assertRaises(TypeError, F, Ratio((7, 3))) + # the type also has an "as_integer_ratio" attribute. + self.assertRaises(TypeError, F, RatioNumber) + # bad ratio + self.assertRaises(TypeError, F, RatioNumber(7)) + self.assertRaises(ValueError, F, RatioNumber((7,))) + self.assertRaises(ValueError, F, RatioNumber((7, 3, 1))) + # only single-argument form + self.assertRaises(TypeError, F, RatioNumber((3, 7)), 11) + self.assertRaises(TypeError, F, 2, RatioNumber((-10, 9))) + def testFromString(self): self.assertEqual((5, 1), _components(F("5"))) self.assertEqual((3, 2), _components(F("3/2"))) diff --git a/Misc/NEWS.d/next/Library/2024-06-08-17-41-11.gh-issue-82017.WpSTGi.rst b/Misc/NEWS.d/next/Library/2024-06-08-17-41-11.gh-issue-82017.WpSTGi.rst new file mode 100644 index 00000000000000..5d362fd554a292 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2024-06-08-17-41-11.gh-issue-82017.WpSTGi.rst @@ -0,0 +1,2 @@ +Added support for converting any numbers that have the +:meth:`!as_integer_ratio` method to a :class:`~fractions.Fraction`. From de5b4237d9c32abe50887f93037d424c8c23ee06 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 9 Jun 2024 09:40:21 +0300 Subject: [PATCH 2/7] Add a versionchanged sirective. --- Doc/library/fractions.rst | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Doc/library/fractions.rst b/Doc/library/fractions.rst index e0f42197989c1d..5ab6de2cf7f602 100644 --- a/Doc/library/fractions.rst +++ b/Doc/library/fractions.rst @@ -28,7 +28,7 @@ another rational number, or from a string. The second version requires that *number* is an instance of :class:`numbers.Rational` or is an instance - of :class:`numbers.Number` and has the :meth:`~as_integer_ratio` method + of :class:`numbers.Number` and has the :meth:`!as_integer_ratio` method (this includes :class:`float` and :class:`decimal.Decimal`). It returns a :class:`Fraction` instance with exactly the same value. Note that due to the @@ -112,6 +112,10 @@ another rational number, or from a string. Formatting of :class:`Fraction` instances without a presentation type now supports fill, alignment, sign handling, minimum width and grouping. + .. versionchanged:: 3.14 + The :class:`Fraction` constructor now accepts any numbers with the + :meth:`!as_integer_ratio` method. + .. attribute:: numerator Numerator of the Fraction in lowest term. From 68bb174517969840feca4755686309359607a4b4 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Tue, 9 Jul 2024 13:20:19 +0300 Subject: [PATCH 3/7] Update Doc/library/fractions.rst Co-authored-by: Sergey B Kirpichev --- Doc/library/fractions.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Doc/library/fractions.rst b/Doc/library/fractions.rst index e0f42197989c1d..31bb6d91425ff0 100644 --- a/Doc/library/fractions.rst +++ b/Doc/library/fractions.rst @@ -31,6 +31,8 @@ another rational number, or from a string. of :class:`numbers.Number` and has the :meth:`~as_integer_ratio` method (this includes :class:`float` and :class:`decimal.Decimal`). It returns a :class:`Fraction` instance with exactly the same value. + Assumed, that the :meth:`~as_integer_ratio` method returns a pair + of coprime integers and last one is positive. 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 From 88bdd201cea6c8e7f2c001e129e54b2cd8d931da Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Tue, 9 Jul 2024 13:32:48 +0300 Subject: [PATCH 4/7] Update an error message. --- Lib/fractions.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Lib/fractions.py b/Lib/fractions.py index c735eaf17438f0..0d9c133027cc32 100644 --- a/Lib/fractions.py +++ b/Lib/fractions.py @@ -278,8 +278,7 @@ def __new__(cls, numerator=0, denominator=None): numerator = -numerator else: - raise TypeError("argument should be a string " - "or a Rational instance") + raise TypeError("argument should be a string or a number") elif type(numerator) is int is type(denominator): pass # *very* normal case From ab9dc178b0c96acbbaa0fdafa4b2a4f106ea0528 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Tue, 9 Jul 2024 13:36:07 +0300 Subject: [PATCH 5/7] Add fast path for float. --- Lib/fractions.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Lib/fractions.py b/Lib/fractions.py index 0d9c133027cc32..6b850c446e5158 100644 --- a/Lib/fractions.py +++ b/Lib/fractions.py @@ -243,8 +243,9 @@ def __new__(cls, numerator=0, denominator=None): self._denominator = numerator.denominator return self - elif (isinstance(numerator, numbers.Number) and - hasattr(numerator, 'as_integer_ratio')): + elif (isinstance(numerator, float) or + (isinstance(numerator, numbers.Number) and + hasattr(numerator, 'as_integer_ratio'))): # Exact conversion self._numerator, self._denominator = numerator.as_integer_ratio() return self From 07bcf8121f8b394f97e4f1ee81f527cd10fffad5 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Thu, 18 Jul 2024 08:19:51 +0300 Subject: [PATCH 6/7] More ducktyping. Add more tests. --- Doc/library/fractions.rst | 9 +++-- Doc/whatsnew/3.14.rst | 2 +- Lib/fractions.py | 2 +- Lib/test/test_fractions.py | 36 ++++++++++++------- ...4-06-08-17-41-11.gh-issue-82017.WpSTGi.rst | 2 +- 5 files changed, 31 insertions(+), 20 deletions(-) diff --git a/Doc/library/fractions.rst b/Doc/library/fractions.rst index 2d9ce64991ab67..a269d813838389 100644 --- a/Doc/library/fractions.rst +++ b/Doc/library/fractions.rst @@ -26,12 +26,11 @@ another rational number, or from a string. with value ``numerator/denominator``. If *denominator* is ``0``, it raises a :exc:`ZeroDivisionError`. - The second version requires that - *number* is an instance of :class:`numbers.Rational` or is an instance - of :class:`numbers.Number` and has the :meth:`!as_integer_ratio` method + The second version requires that *number* is an instance of + :class:`numbers.Rational` or has the :meth:`!as_integer_ratio` method (this includes :class:`float` and :class:`decimal.Decimal`). It returns a :class:`Fraction` instance with exactly the same value. - Assumed, that the :meth:`~as_integer_ratio` method returns a pair + Assumed, that the :meth:`!as_integer_ratio` method returns a pair of coprime integers and last one is positive. Note that due to the usual issues with binary floating-point (see :ref:`tut-fp-issues`), the @@ -115,7 +114,7 @@ another rational number, or from a string. now supports fill, alignment, sign handling, minimum width and grouping. .. versionchanged:: 3.14 - The :class:`Fraction` constructor now accepts any numbers with the + The :class:`Fraction` constructor now accepts any objects with the :meth:`!as_integer_ratio` method. .. attribute:: numerator diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst index 5d9819f06c61b9..777faafe59b4f5 100644 --- a/Doc/whatsnew/3.14.rst +++ b/Doc/whatsnew/3.14.rst @@ -103,7 +103,7 @@ ast fractions --------- -Added support for converting any numbers that have the +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`.) diff --git a/Lib/fractions.py b/Lib/fractions.py index 6b850c446e5158..34fd0803d1b1ab 100644 --- a/Lib/fractions.py +++ b/Lib/fractions.py @@ -244,7 +244,7 @@ def __new__(cls, numerator=0, denominator=None): return self elif (isinstance(numerator, float) or - (isinstance(numerator, numbers.Number) and + (not isinstance(numerator, type) and hasattr(numerator, 'as_integer_ratio'))): # Exact conversion self._numerator, self._denominator = numerator.as_integer_ratio() diff --git a/Lib/test/test_fractions.py b/Lib/test/test_fractions.py index b7039bb23c1b25..12c42126301265 100644 --- a/Lib/test/test_fractions.py +++ b/Lib/test/test_fractions.py @@ -360,22 +360,34 @@ def __init__(self, ratio): self._ratio = ratio def as_integer_ratio(self): return self._ratio - class RatioNumber(Ratio): - pass - numbers.Number.register(RatioNumber) - self.assertEqual((7, 3), _components(F(RatioNumber((7, 3))))) - # not a number - self.assertRaises(TypeError, F, Ratio((7, 3))) + self.assertEqual((7, 3), _components(F(Ratio((7, 3))))) + errmsg = "argument should be a string or a number" # the type also has an "as_integer_ratio" attribute. - self.assertRaises(TypeError, F, RatioNumber) + self.assertRaisesRegex(TypeError, errmsg, F, Ratio) # bad ratio - self.assertRaises(TypeError, F, RatioNumber(7)) - self.assertRaises(ValueError, F, RatioNumber((7,))) - self.assertRaises(ValueError, F, RatioNumber((7, 3, 1))) + self.assertRaises(TypeError, F, Ratio(7)) + self.assertRaises(ValueError, F, Ratio((7,))) + self.assertRaises(ValueError, F, Ratio((7, 3, 1))) # only single-argument form - self.assertRaises(TypeError, F, RatioNumber((3, 7)), 11) - self.assertRaises(TypeError, F, 2, RatioNumber((-10, 9))) + self.assertRaises(TypeError, F, Ratio((3, 7)), 11) + self.assertRaises(TypeError, F, 2, Ratio((-10, 9))) + + # as_integer_ratio not defined in a class + class A: + pass + a = A() + a.as_integer_ratio = lambda: (9, 5) + self.assertEqual((9, 5), _components(F(a))) + + # as_integer_ratio defined in a metaclass + class M(type): + def as_integer_ratio(self): + return (11, 9) + class B(metaclass=M): + pass + self.assertRaisesRegex(TypeError, errmsg, F, B) + self.assertRaisesRegex(TypeError, errmsg, F, B()) def testFromString(self): self.assertEqual((5, 1), _components(F("5"))) diff --git a/Misc/NEWS.d/next/Library/2024-06-08-17-41-11.gh-issue-82017.WpSTGi.rst b/Misc/NEWS.d/next/Library/2024-06-08-17-41-11.gh-issue-82017.WpSTGi.rst index 5d362fd554a292..7decee7ff3384e 100644 --- a/Misc/NEWS.d/next/Library/2024-06-08-17-41-11.gh-issue-82017.WpSTGi.rst +++ b/Misc/NEWS.d/next/Library/2024-06-08-17-41-11.gh-issue-82017.WpSTGi.rst @@ -1,2 +1,2 @@ -Added support for converting any numbers that have the +Added support for converting any objects that have the :meth:`!as_integer_ratio` method to a :class:`~fractions.Fraction`. From 1539af58ae89cc4c32d6e205c4c3243bb0059cf0 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Thu, 18 Jul 2024 08:43:42 +0300 Subject: [PATCH 7/7] Add an index entry. --- Doc/library/fractions.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Doc/library/fractions.rst b/Doc/library/fractions.rst index a269d813838389..410b176c5d5084 100644 --- a/Doc/library/fractions.rst +++ b/Doc/library/fractions.rst @@ -17,6 +17,8 @@ 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. +.. index:: single: as_integer_ratio() + .. class:: Fraction(numerator=0, denominator=1) Fraction(number) Fraction(string)