From aa8249a2e1bf5d41993f018f430bcb61b9f4f06b Mon Sep 17 00:00:00 2001 From: Sergey B Kirpichev Date: Fri, 28 May 2021 09:18:06 +0300 Subject: [PATCH 1/9] bpo-44258: support PEP 515 for Fraction's initialization from string --- Lib/fractions.py | 67 ++++++++++--------- Lib/test/test_fractions.py | 58 ++++++++++++++++ .../2021-05-28-09-43-33.bpo-44258.nh5F7R.rst | 1 + 3 files changed, 95 insertions(+), 31 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2021-05-28-09-43-33.bpo-44258.nh5F7R.rst diff --git a/Lib/fractions.py b/Lib/fractions.py index 64a8959d7d48e5..3e88abac131e9e 100644 --- a/Lib/fractions.py +++ b/Lib/fractions.py @@ -21,17 +21,17 @@ _PyHASH_INF = sys.hash_info.inf _RATIONAL_FORMAT = re.compile(r""" - \A\s* # optional whitespace at the start, then - (?P[-+]?) # an optional sign, then - (?=\d|\.\d) # lookahead for digit or .digit - (?P\d*) # numerator (possibly empty) - (?: # followed by - (?:/(?P\d+))? # an optional denominator - | # or - (?:\.(?P\d*))? # an optional fractional part - (?:E(?P[-+]?\d+))? # and optional exponent + \A\s* # optional whitespace at the start, then + (?P[-+]?) # an optional sign, then + (?=\d|\.\d) # lookahead for digit or .digit + (?P[_\d]*) # numerator (possibly empty) + (?: # followed by + (?:/(?P\d[_\d]*))? # an optional denominator + | # or + (?:\.(?P[_\d]*))? # an optional fractional part + (?:E(?P[-+]?[_\d]+))? # and optional exponent ) - \s*\Z # and optional whitespace to finish + \s*\Z # and optional whitespace to finish """, re.VERBOSE | re.IGNORECASE) @@ -114,27 +114,32 @@ def __new__(cls, numerator=0, denominator=None, *, _normalize=True): if m is None: raise ValueError('Invalid literal for Fraction: %r' % numerator) - numerator = int(m.group('num') or '0') - denom = m.group('denom') - if denom: - denominator = int(denom) - else: - denominator = 1 - decimal = m.group('decimal') - if decimal: - scale = 10**len(decimal) - numerator = numerator * scale + int(decimal) - denominator *= scale - exp = m.group('exp') - if exp: - exp = int(exp) - if exp >= 0: - numerator *= 10**exp - else: - denominator *= 10**-exp - if m.group('sign') == '-': - numerator = -numerator - + try: + numerator_copy = numerator + numerator = int(m.group('num') or '0') + denom = m.group('denom') + if denom: + denominator = int(denom) + else: + denominator = 1 + decimal = m.group('decimal') + if decimal: + decimal = int(decimal) + scale = 10**len(str(decimal)) + numerator = numerator * scale + decimal + denominator *= scale + exp = m.group('exp') + if exp: + exp = int(exp) + if exp >= 0: + numerator *= 10**exp + else: + denominator *= 10**-exp + if m.group('sign') == '-': + numerator = -numerator + except ValueError: + raise ValueError('Invalid literal for Fraction: %r' % + numerator_copy) else: raise TypeError("argument should be a string " "or a Rational instance") diff --git a/Lib/test/test_fractions.py b/Lib/test/test_fractions.py index 949ddd9072862f..2a938315fc59e2 100644 --- a/Lib/test/test_fractions.py +++ b/Lib/test/test_fractions.py @@ -173,6 +173,10 @@ def testFromString(self): 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.assertRaisesMessage( ZeroDivisionError, "Fraction(3, 0)", @@ -210,6 +214,60 @@ def testFromString(self): # 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'", + 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'", + 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.1_'", + F, "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_'", + F, "1.1e+1_") + self.assertRaisesMessage( + ValueError, "Invalid literal for Fraction: '1.1e+1__1'", + F, "1.1e+1__1") 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 index 00000000000000..b9636899700f6e --- /dev/null +++ b/Misc/NEWS.d/next/Library/2021-05-28-09-43-33.bpo-44258.nh5F7R.rst @@ -0,0 +1 @@ +Support PEP 515 for Fraction's initialization from string. From 8c96355b0916b227434efa3c333ac1b483df9f7b Mon Sep 17 00:00:00 2001 From: Sergey B Kirpichev Date: Fri, 28 May 2021 11:04:44 +0300 Subject: [PATCH 2/9] regexps's version --- Lib/fractions.py | 68 +++++++++++++++++++++++------------------------- 1 file changed, 32 insertions(+), 36 deletions(-) diff --git a/Lib/fractions.py b/Lib/fractions.py index 3e88abac131e9e..a2cdb03b919a3e 100644 --- a/Lib/fractions.py +++ b/Lib/fractions.py @@ -21,17 +21,17 @@ _PyHASH_INF = sys.hash_info.inf _RATIONAL_FORMAT = re.compile(r""" - \A\s* # optional whitespace at the start, then - (?P[-+]?) # an optional sign, then - (?=\d|\.\d) # lookahead for digit or .digit - (?P[_\d]*) # numerator (possibly empty) - (?: # followed by - (?:/(?P\d[_\d]*))? # an optional denominator - | # or - (?:\.(?P[_\d]*))? # an optional fractional part - (?:E(?P[-+]?[_\d]+))? # and optional exponent + \A\s* # optional whitespace at the start, + (?P[-+]?) # an optional sign, then + (?=\d|\.\d) # lookahead for digit or .digit + (?P((\d+_?)*\d|\d*)) # numerator (possibly empty) + (?: # followed by + (?:/(?P(\d+_?)*\d+))? # an optional denominator + | # or + (?:\.(?P((\d+_?)*\d|\d*)))? # an optional fractional part + (?:E(?P[-+]?(\d+_?)*\d+))? # and optional exponent ) - \s*\Z # and optional whitespace to finish + \s*\Z # and optional whitespace to finish """, re.VERBOSE | re.IGNORECASE) @@ -114,32 +114,28 @@ def __new__(cls, numerator=0, denominator=None, *, _normalize=True): if m is None: raise ValueError('Invalid literal for Fraction: %r' % numerator) - try: - numerator_copy = numerator - numerator = int(m.group('num') or '0') - denom = m.group('denom') - if denom: - denominator = int(denom) - else: - denominator = 1 - decimal = m.group('decimal') - if decimal: - decimal = int(decimal) - scale = 10**len(str(decimal)) - numerator = numerator * scale + decimal - denominator *= scale - exp = m.group('exp') - if exp: - exp = int(exp) - if exp >= 0: - numerator *= 10**exp - else: - denominator *= 10**-exp - if m.group('sign') == '-': - numerator = -numerator - except ValueError: - raise ValueError('Invalid literal for Fraction: %r' % - numerator_copy) + numerator = int(m.group('num') or '0') + denominator = m.group('den') + if denominator: + denominator = int(denominator) + else: + denominator = 1 + decimal = m.group('decimal') + if decimal: + decimal = int(decimal) + scale = 10**len(str(decimal)) + numerator = numerator * scale + decimal + denominator *= scale + exp = m.group('exp') + if exp: + exp = int(exp) + if exp >= 0: + numerator *= 10**exp + else: + denominator *= 10**-exp + if m.group('sign') == '-': + numerator = -numerator + else: raise TypeError("argument should be a string " "or a Rational instance") From 38b16a9127c257b4823abd258c1448c871f944e5 Mon Sep 17 00:00:00 2001 From: Sergey B Kirpichev Date: Sat, 29 May 2021 05:06:57 +0300 Subject: [PATCH 3/9] A different regexps version, which doesn't suffer from catastrophic backtracking --- Lib/fractions.py | 20 ++++++++++---------- Lib/test/test_fractions.py | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/Lib/fractions.py b/Lib/fractions.py index a2cdb03b919a3e..a68a9e040b5381 100644 --- a/Lib/fractions.py +++ b/Lib/fractions.py @@ -21,17 +21,17 @@ _PyHASH_INF = sys.hash_info.inf _RATIONAL_FORMAT = re.compile(r""" - \A\s* # optional whitespace at the start, - (?P[-+]?) # an optional sign, then - (?=\d|\.\d) # lookahead for digit or .digit - (?P((\d+_?)*\d|\d*)) # numerator (possibly empty) - (?: # followed by - (?:/(?P(\d+_?)*\d+))? # an optional denominator - | # or - (?:\.(?P((\d+_?)*\d|\d*)))? # an optional fractional part - (?:E(?P[-+]?(\d+_?)*\d+))? # and optional exponent + \A\s* # optional whitespace at the start, + (?P[-+]?) # an optional sign, then + (?=\d|\.\d) # lookahead for digit or .digit + (?P\d*|\d+(_\d+)*) # numerator (possibly empty) + (?: # followed by + (?:/(?P\d+(_\d+)*))? # an optional denominator + | # or + (?:\.(?Pd*|\d+(_\d+)*))? # an optional fractional part + (?:E(?P[-+]?\d+(_\d+)*))? # and optional exponent ) - \s*\Z # and optional whitespace to finish + \s*\Z # and optional whitespace to finish """, re.VERBOSE | re.IGNORECASE) diff --git a/Lib/test/test_fractions.py b/Lib/test/test_fractions.py index 2a938315fc59e2..0cb01b5eaf4f85 100644 --- a/Lib/test/test_fractions.py +++ b/Lib/test/test_fractions.py @@ -268,6 +268,20 @@ def testFromString(self): 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) From db9652ebe3a1a2971d0a90c3e3f2193822a8f228 Mon Sep 17 00:00:00 2001 From: Sergey B Kirpichev Date: Sun, 30 May 2021 06:14:59 +0300 Subject: [PATCH 4/9] revert denom -> den --- Lib/fractions.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Lib/fractions.py b/Lib/fractions.py index e524026dd26189..01054e5db1f0cf 100644 --- a/Lib/fractions.py +++ b/Lib/fractions.py @@ -26,7 +26,7 @@ (?=\d|\.\d) # lookahead for digit or .digit (?P\d*|\d+(_\d+)*) # numerator (possibly empty) (?: # followed by - (?:/(?P\d+(_\d+)*))? # an optional denominator + (?:/(?P\d+(_\d+)*))? # an optional denominator | # or (?:\.(?Pd*|\d+(_\d+)*))? # an optional fractional part (?:E(?P[-+]?\d+(_\d+)*))? # and optional exponent @@ -115,9 +115,9 @@ def __new__(cls, numerator=0, denominator=None, *, _normalize=True): raise ValueError('Invalid literal for Fraction: %r' % numerator) numerator = int(m.group('num') or '0') - denominator = m.group('den') - if denominator: - denominator = int(denominator) + denom = m.group('denom') + if denom: + denominator = int(denom) else: denominator = 1 decimal = m.group('decimal') From 6336aef9287d3c07adce3cf32f052006cfb60f7a Mon Sep 17 00:00:00 2001 From: Sergey B Kirpichev Date: Sun, 30 May 2021 06:20:48 +0300 Subject: [PATCH 5/9] strip "_" from the decimal str, add few tests --- Lib/fractions.py | 6 +++--- Lib/test/test_fractions.py | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Lib/fractions.py b/Lib/fractions.py index 01054e5db1f0cf..180cd94c2879cc 100644 --- a/Lib/fractions.py +++ b/Lib/fractions.py @@ -122,9 +122,9 @@ def __new__(cls, numerator=0, denominator=None, *, _normalize=True): denominator = 1 decimal = m.group('decimal') if decimal: - decimal = int(decimal) - scale = 10**len(str(decimal)) - numerator = numerator * scale + decimal + decimal = decimal.replace('_', '') + scale = 10**len(decimal) + numerator = numerator * scale + int(decimal) denominator *= scale exp = m.group('exp') if exp: diff --git a/Lib/test/test_fractions.py b/Lib/test/test_fractions.py index 0cb01b5eaf4f85..96e5573a9ef725 100644 --- a/Lib/test/test_fractions.py +++ b/Lib/test/test_fractions.py @@ -177,6 +177,8 @@ def testFromString(self): 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)", From b11ef1e005877a17aac754c4f306bd956f6d79fe Mon Sep 17 00:00:00 2001 From: Sergey B Kirpichev Date: Sun, 30 May 2021 06:24:05 +0300 Subject: [PATCH 6/9] drop redundant tests --- Lib/test/test_fractions.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/Lib/test/test_fractions.py b/Lib/test/test_fractions.py index 96e5573a9ef725..bbf7709fe959be 100644 --- a/Lib/test/test_fractions.py +++ b/Lib/test/test_fractions.py @@ -219,9 +219,6 @@ def testFromString(self): 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") @@ -243,9 +240,6 @@ def testFromString(self): 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'", F, "1/_1") @@ -258,15 +252,9 @@ def testFromString(self): self.assertRaisesMessage( ValueError, "Invalid literal for Fraction: '1.1__1'", F, "1.1__1") - self.assertRaisesMessage( - ValueError, "Invalid literal for Fraction: '1.1_'", - F, "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_'", - F, "1.1e+1_") self.assertRaisesMessage( ValueError, "Invalid literal for Fraction: '1.1e+1__1'", F, "1.1e+1__1") From 5f7e32aa1ebacbb8c7a768ef87466a532db7b8e3 Mon Sep 17 00:00:00 2001 From: Sergey B Kirpichev Date: Sun, 30 May 2021 12:17:23 +0300 Subject: [PATCH 7/9] Add versionchanged & whatsnew entry --- Doc/library/fractions.rst | 3 +++ Doc/whatsnew/3.11.rst | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/Doc/library/fractions.rst b/Doc/library/fractions.rst index a4d006eb58ffeb..e46dbedc64085d 100644 --- a/Doc/library/fractions.rst +++ b/Doc/library/fractions.rst @@ -89,6 +89,9 @@ 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 + Supported :PEP:`515`-style initialization from string. + .. attribute:: numerator Numerator of the Fraction in lowest term. diff --git a/Doc/whatsnew/3.11.rst b/Doc/whatsnew/3.11.rst index a178095be0d0da..baf5e3f107f910 100644 --- a/Doc/whatsnew/3.11.rst +++ b/Doc/whatsnew/3.11.rst @@ -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 ============= From 983b67d11f1e427e4d426a32096b33fa353962ba Mon Sep 17 00:00:00 2001 From: Sergey B Kirpichev Date: Sun, 30 May 2021 12:29:52 +0300 Subject: [PATCH 8/9] Amend Fraction constructor docs --- Doc/library/fractions.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Doc/library/fractions.rst b/Doc/library/fractions.rst index e46dbedc64085d..91538ee0db65da 100644 --- a/Doc/library/fractions.rst +++ b/Doc/library/fractions.rst @@ -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. From 076b396594fe887c701c48382f951b6772dfa415 Mon Sep 17 00:00:00 2001 From: Sergey B Kirpichev Date: Mon, 31 May 2021 16:40:49 +0300 Subject: [PATCH 9/9] Change .. versionchanged:... --- Doc/library/fractions.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Doc/library/fractions.rst b/Doc/library/fractions.rst index 91538ee0db65da..d04de8f8e95a61 100644 --- a/Doc/library/fractions.rst +++ b/Doc/library/fractions.rst @@ -91,7 +91,8 @@ another rational number, or from a string. Previously, the GCD type depended on *numerator* and *denominator*. .. versionchanged:: 3.11 - Supported :PEP:`515`-style initialization from string. + Underscores are now permitted when creating a :class:`Fraction` instance + from a string, following :PEP:`515` rules. .. attribute:: numerator