]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
bpo-44258: support PEP 515 for Fraction's initialization from string (GH-26422)
authorSergey B Kirpichev <skirpichev@gmail.com>
Mon, 7 Jun 2021 07:06:33 +0000 (10:06 +0300)
committerGitHub <noreply@github.com>
Mon, 7 Jun 2021 07:06:33 +0000 (08:06 +0100)
* bpo-44258: support PEP 515 for Fraction's initialization from string

* regexps's version

* A different regexps version, which doesn't suffer from catastrophic backtracking

* revert denom -> den

* strip "_" from the decimal str, add few tests

* drop redundant tests

* Add versionchanged & whatsnew entry

* Amend Fraction constructor docs

* Change .. versionchanged:...

Doc/library/fractions.rst
Doc/whatsnew/3.11.rst
Lib/fractions.py
Lib/test/test_fractions.py
Misc/NEWS.d/next/Library/2021-05-28-09-43-33.bpo-44258.nh5F7R.rst [new file with mode: 0644]

index a4d006eb58ffeb487aba0d1688fa6911dceb4f74..d04de8f8e95a61a99f3d2925cd3c9811248ccbde 100644 (file)
@@ -42,7 +42,8 @@ another rational number, or from a string.
 
    where the optional ``sign`` may be either '+' or '-' and
    ``numerator`` and ``denominator`` (if present) are strings of
-   decimal digits.  In addition, any string that represents a finite
+   decimal digits (underscores may be used to delimit digits as with
+   integral literals in code).  In addition, any string that represents a finite
    value and is accepted by the :class:`float` constructor is also
    accepted by the :class:`Fraction` constructor.  In either form the
    input string may also have leading and/or trailing whitespace.
@@ -89,6 +90,10 @@ another rational number, or from a string.
       and *denominator*. :func:`math.gcd` always return a :class:`int` type.
       Previously, the GCD type depended on *numerator* and *denominator*.
 
+   .. versionchanged:: 3.11
+      Underscores are now permitted when creating a :class:`Fraction` instance
+      from a string, following :PEP:`515` rules.
+
    .. attribute:: numerator
 
       Numerator of the Fraction in lowest term.
index 1ea8cbaf9a82a33fcf03df49a6b78be9e0fcde75..8c81b08de2cfbaffe7bcee43adb0203f77eef879 100644 (file)
@@ -86,6 +86,11 @@ New Modules
 Improved Modules
 ================
 
+fractions
+---------
+
+Support :PEP:`515`-style initialization of :class:`~fractions.Fraction` from
+string.  (Contributed by Sergey B Kirpichev in :issue:`44258`.)
 
 Optimizations
 =============
index 66e6831a5c7bcc34c56d9dc571a99c344b68838f..180cd94c2879cc6a536c09e94095224296e0d284 100644 (file)
@@ -21,17 +21,17 @@ _PyHASH_MODULUS = sys.hash_info.modulus
 _PyHASH_INF = sys.hash_info.inf
 
 _RATIONAL_FORMAT = re.compile(r"""
-    \A\s*                      # optional whitespace at the start, then
-    (?P<sign>[-+]?)            # an optional sign, then
-    (?=\d|\.\d)                # lookahead for digit or .digit
-    (?P<num>\d*)               # numerator (possibly empty)
-    (?:                        # followed by
-       (?:/(?P<denom>\d+))?    # an optional denominator
-    |                          # or
-       (?:\.(?P<decimal>\d*))? # an optional fractional part
-       (?:E(?P<exp>[-+]?\d+))? # and optional exponent
+    \A\s*                                 # optional whitespace at the start,
+    (?P<sign>[-+]?)                       # an optional sign, then
+    (?=\d|\.\d)                           # lookahead for digit or .digit
+    (?P<num>\d*|\d+(_\d+)*)               # numerator (possibly empty)
+    (?:                                   # followed by
+       (?:/(?P<denom>\d+(_\d+)*))?        # an optional denominator
+    |                                     # or
+       (?:\.(?P<decimal>d*|\d+(_\d+)*))?  # an optional fractional part
+       (?:E(?P<exp>[-+]?\d+(_\d+)*))?     # and optional exponent
     )
-    \s*\Z                      # and optional whitespace to finish
+    \s*\Z                                 # and optional whitespace to finish
 """, re.VERBOSE | re.IGNORECASE)
 
 
@@ -122,6 +122,7 @@ class Fraction(numbers.Rational):
                     denominator = 1
                     decimal = m.group('decimal')
                     if decimal:
+                        decimal = decimal.replace('_', '')
                         scale = 10**len(decimal)
                         numerator = numerator * scale + int(decimal)
                         denominator *= scale
index 949ddd9072862f6d219a6023098484a085b87123..bbf7709fe959bef50024cf051e9a6dd87ac29a39 100644 (file)
@@ -173,6 +173,12 @@ class FractionTest(unittest.TestCase):
         self.assertEqual((-12300, 1), _components(F("-1.23e4")))
         self.assertEqual((0, 1), _components(F(" .0e+0\t")))
         self.assertEqual((0, 1), _components(F("-0.000e0")))
+        self.assertEqual((123, 1), _components(F("1_2_3")))
+        self.assertEqual((41, 107), _components(F("1_2_3/3_2_1")))
+        self.assertEqual((6283, 2000), _components(F("3.14_15")))
+        self.assertEqual((6283, 2*10**13), _components(F("3.14_15e-1_0")))
+        self.assertEqual((101, 100), _components(F("1.01")))
+        self.assertEqual((101, 100), _components(F("1.0_1")))
 
         self.assertRaisesMessage(
             ZeroDivisionError, "Fraction(3, 0)",
@@ -210,6 +216,62 @@ class FractionTest(unittest.TestCase):
             # Allow 3. and .3, but not .
             ValueError, "Invalid literal for Fraction: '.'",
             F, ".")
+        self.assertRaisesMessage(
+            ValueError, "Invalid literal for Fraction: '_'",
+            F, "_")
+        self.assertRaisesMessage(
+            ValueError, "Invalid literal for Fraction: '_1'",
+            F, "_1")
+        self.assertRaisesMessage(
+            ValueError, "Invalid literal for Fraction: '1__2'",
+            F, "1__2")
+        self.assertRaisesMessage(
+            ValueError, "Invalid literal for Fraction: '/_'",
+            F, "/_")
+        self.assertRaisesMessage(
+            ValueError, "Invalid literal for Fraction: '1_/'",
+            F, "1_/")
+        self.assertRaisesMessage(
+            ValueError, "Invalid literal for Fraction: '_1/'",
+            F, "_1/")
+        self.assertRaisesMessage(
+            ValueError, "Invalid literal for Fraction: '1__2/'",
+            F, "1__2/")
+        self.assertRaisesMessage(
+            ValueError, "Invalid literal for Fraction: '1/_'",
+            F, "1/_")
+        self.assertRaisesMessage(
+            ValueError, "Invalid literal for Fraction: '1/_1'",
+            F, "1/_1")
+        self.assertRaisesMessage(
+            ValueError, "Invalid literal for Fraction: '1/1__2'",
+            F, "1/1__2")
+        self.assertRaisesMessage(
+            ValueError, "Invalid literal for Fraction: '1._111'",
+            F, "1._111")
+        self.assertRaisesMessage(
+            ValueError, "Invalid literal for Fraction: '1.1__1'",
+            F, "1.1__1")
+        self.assertRaisesMessage(
+            ValueError, "Invalid literal for Fraction: '1.1e+_1'",
+            F, "1.1e+_1")
+        self.assertRaisesMessage(
+            ValueError, "Invalid literal for Fraction: '1.1e+1__1'",
+            F, "1.1e+1__1")
+        # Test catastrophic backtracking.
+        val = "9"*50 + "_"
+        self.assertRaisesMessage(
+            ValueError, "Invalid literal for Fraction: '" + val + "'",
+            F, val)
+        self.assertRaisesMessage(
+            ValueError, "Invalid literal for Fraction: '1/" + val + "'",
+            F, "1/" + val)
+        self.assertRaisesMessage(
+            ValueError, "Invalid literal for Fraction: '1." + val + "'",
+            F, "1." + val)
+        self.assertRaisesMessage(
+            ValueError, "Invalid literal for Fraction: '1.1+e" + val + "'",
+            F, "1.1+e" + val)
 
     def testImmutable(self):
         r = F(7, 3)
diff --git a/Misc/NEWS.d/next/Library/2021-05-28-09-43-33.bpo-44258.nh5F7R.rst b/Misc/NEWS.d/next/Library/2021-05-28-09-43-33.bpo-44258.nh5F7R.rst
new file mode 100644 (file)
index 0000000..b963689
--- /dev/null
@@ -0,0 +1 @@
+Support PEP 515 for Fraction's initialization from string.