From 8cf8d31ce701235491a0761236cd9f9dcf55146c Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Sat, 3 Dec 2022 17:05:53 +0000 Subject: [PATCH 01/31] Add '.f' formatting for Fraction objects --- Lib/fractions.py | 99 +++++++++++++++++++++ Lib/test/test_fractions.py | 177 +++++++++++++++++++++++++++++++++++++ 2 files changed, 276 insertions(+) diff --git a/Lib/fractions.py b/Lib/fractions.py index 75c7df14e1b9c7..1d64910724d87d 100644 --- a/Lib/fractions.py +++ b/Lib/fractions.py @@ -310,6 +310,105 @@ def __str__(self): else: return '%s/%s' % (self._numerator, self._denominator) + def __format__(self, format_spec, /): + """Format this fraction according to the given format specification.""" + + # Backwards compatiblility with existing formatting. + if not format_spec: + return str(self) + + # Pattern matcher for the format spec; only supports "f" so far + FORMAT_SPEC_MATCHER = re.compile(r""" + (?: + (?P.)? + (?P[<>=^]) + )? + (?P[-+ ]?) + (?P\#)? + (?P0(?=\d))? + (?P\d+)? + (?P[,_])? + (?:\.(?P\d+))? + f + """, re.DOTALL | re.VERBOSE).fullmatch + + # Validate and parse the format specifier. + match = FORMAT_SPEC_MATCHER(format_spec) + if match is None: + raise ValueError( + f"Invalid format specifier {format_spec!r} " + f"for object of type {type(self).__name__!r}" + ) + elif match["align"] is not None and match["zeropad"] is not None: + # Avoid the temptation to guess. + raise ValueError( + f"Invalid format specifier {format_spec!r} " + f"for object of type {type(self).__name__!r}; " + "can't use explicit alignment when zero-padding" + ) + else: + fill = match["fill"] or " " + align = match["align"] or ">" + pos_sign = "" if match["sign"] == "-" else match["sign"] + neg_sign = "-" + alternate_form = bool(match["alt"]) + zeropad = bool(match["zeropad"]) + minimumwidth = int(match["minimumwidth"] or "0") + thousands_sep = match["thousands_sep"] + precision = int(match["precision"] or "6") + + # Get sign and output digits for the target number + negative = self < 0 + digits = str(round(abs(self) * 10**precision)) + + # Assemble the output: before padding, it has the form + # f"{sign}{leading}{trailing}", where `leading` includes thousands + # separators if necessary, and `trailing` includes the decimal + # separator where appropriate. + digits = digits.zfill(precision + 1) + dot_pos = len(digits) - precision + sign = neg_sign if negative else pos_sign + separator = "." if precision or alternate_form else "" + trailing = separator + digits[dot_pos:] + leading = digits[:dot_pos] + + # Do zero padding if required. + if zeropad: + min_leading = minimumwidth - len(sign) - len(trailing) + # When adding thousands separators, they'll be added to the + # zero-padded portion too, so we need to compensate. + leading = leading.zfill( + 3 * min_leading // 4 + 1 if thousands_sep else min_leading + ) + + # Insert thousands separators if required. + if thousands_sep: + first_pos = 1 + (len(leading) - 1) % 3 + leading = leading[:first_pos] + "".join( + thousands_sep + leading[pos:pos+3] + for pos in range(first_pos, len(leading), 3) + ) + + after_sign = leading + trailing + + # Pad if a minimum width was given and we haven't already zero padded. + if zeropad or minimumwidth is None: + result = sign + after_sign + else: + padding = fill * (minimumwidth - len(sign) - len(after_sign)) + if align == ">": + result = padding + sign + after_sign + elif align == "<": + result = sign + after_sign + padding + elif align == "=": + result = sign + padding + after_sign + else: + # Centered, with a leftwards bias when padding length is odd. + assert align == "^" + half = len(padding)//2 + result = padding[:half] + sign + after_sign + padding[half:] + return result + def _operator_fallbacks(monomorphic_operator, fallback_operator): """Generates forward and reverse operators given a purely-rational operator and a function from the operator module. diff --git a/Lib/test/test_fractions.py b/Lib/test/test_fractions.py index 7fa9dbea905b59..f448e85a68ae74 100644 --- a/Lib/test/test_fractions.py +++ b/Lib/test/test_fractions.py @@ -830,6 +830,183 @@ def denominator(self): self.assertEqual(type(f.numerator), myint) self.assertEqual(type(f.denominator), myint) + def test_format(self): + # Triples (fraction, specification, expected_result) + testcases = [ + # Case inherited from object - equivalent to str() + (F(1, 3), '', '1/3'), + (F(-1, 3), '', '-1/3'), + # Simple .f formatting + (F(0, 1), '.2f', '0.00'), + (F(1, 3), '.2f', '0.33'), + (F(2, 3), '.2f', '0.67'), + (F(4, 3), '.2f', '1.33'), + (F(1, 8), '.2f', '0.12'), + (F(3, 8), '.2f', '0.38'), + (F(1, 13), '.2f', '0.08'), + (F(1, 199), '.2f', '0.01'), + (F(1, 200), '.2f', '0.00'), + (F(22, 7), '.5f', '3.14286'), + (F('399024789'), '.2f', '399024789.00'), + # Large precision (more than float can provide) + (F(104348, 33215), '.50f', + '3.14159265392142104470871594159265392142104470871594'), + # Precision defaults to 6 if not given + (F(22, 7), 'f', '3.142857'), + (F(0), 'f', '0.000000'), + (F(-22, 7), 'f', '-3.142857'), + # Round-ties-to-even checks + (F('1.225'), '.2f', '1.22'), + (F('1.2250000001'), '.2f', '1.23'), + (F('1.2349999999'), '.2f', '1.23'), + (F('1.235'), '.2f', '1.24'), + (F('1.245'), '.2f', '1.24'), + (F('1.2450000001'), '.2f', '1.25'), + (F('1.2549999999'), '.2f', '1.25'), + (F('1.255'), '.2f', '1.26'), + (F('-1.225'), '.2f', '-1.22'), + (F('-1.2250000001'), '.2f', '-1.23'), + (F('-1.2349999999'), '.2f', '-1.23'), + (F('-1.235'), '.2f', '-1.24'), + (F('-1.245'), '.2f', '-1.24'), + (F('-1.2450000001'), '.2f', '-1.25'), + (F('-1.2549999999'), '.2f', '-1.25'), + (F('-1.255'), '.2f', '-1.26'), + # Negatives and sign handling + (F(2, 3), '.2f', '0.67'), + (F(2, 3), '-.2f', '0.67'), + (F(2, 3), '+.2f', '+0.67'), + (F(2, 3), ' .2f', ' 0.67'), + (F(-2, 3), '.2f', '-0.67'), + (F(-2, 3), '-.2f', '-0.67'), + (F(-2, 3), '+.2f', '-0.67'), + (F(-2, 3), ' .2f', '-0.67'), + # Formatting to zero places + (F(1, 2), '.0f', '0'), + (F(22, 7), '.0f', '3'), + (F(-22, 7), '.0f', '-3'), + # Formatting to zero places, alternate form + (F(1, 2), '#.0f', '0.'), + (F(22, 7), '#.0f', '3.'), + (F(-22, 7), '#.0f', '-3.'), + # Corner-case: leading zeros are allowed in the precision + (F(2, 3), '.02f', '0.67'), + (F(22, 7), '.000f', '3'), + # Specifying a minimum width + (F(2, 3), '6.2f', ' 0.67'), + (F(12345), '6.2f', '12345.00'), + (F(12345), '12f', '12345.000000'), + # Fill and alignment + (F(2, 3), '>6.2f', ' 0.67'), + (F(2, 3), '<6.2f', '0.67 '), + (F(2, 3), '^3.2f', '0.67'), + (F(2, 3), '^4.2f', '0.67'), + (F(2, 3), '^5.2f', '0.67 '), + (F(2, 3), '^6.2f', ' 0.67 '), + (F(2, 3), '^7.2f', ' 0.67 '), + (F(2, 3), '^8.2f', ' 0.67 '), + # '=' alignment + (F(-2, 3), '=+8.2f', '- 0.67'), + (F(2, 3), '=+8.2f', '+ 0.67'), + # Fill character + (F(-2, 3), 'X>3.2f', '-0.67'), + (F(-2, 3), 'X>7.2f', 'XX-0.67'), + (F(-2, 3), 'X<7.2f', '-0.67XX'), + (F(-2, 3), 'X^7.2f', 'X-0.67X'), + (F(-2, 3), 'X=7.2f', '-XX0.67'), + (F(-2, 3), ' >7.2f', ' -0.67'), + # Corner cases: weird fill characters + (F(-2, 3), '\x00>7.2f', '\x00\x00-0.67'), + (F(-2, 3), '\n>7.2f', '\n\n-0.67'), + (F(-2, 3), '\t>7.2f', '\t\t-0.67'), + # Zero-padding + (F(-2, 3), '07.2f', '-000.67'), + (F(-2, 3), '-07.2f', '-000.67'), + (F(2, 3), '+07.2f', '+000.67'), + (F(2, 3), ' 07.2f', ' 000.67'), + (F(2, 3), '0.2f', '0.67'), + # Thousands separator (only affects portion before the point) + (F(2, 3), ',.2f', '0.67'), + (F(2, 3), ',.7f', '0.6666667'), + (F('123456.789'), ',.2f', '123,456.79'), + (F('1234567'), ',.2f', '1,234,567.00'), + (F('12345678'), ',.2f', '12,345,678.00'), + (F('12345678'), ',f', '12,345,678.000000'), + # Underscore as thousands separator + (F(2, 3), '_.2f', '0.67'), + (F(2, 3), '_.7f', '0.6666667'), + (F('123456.789'), '_.2f', '123_456.79'), + (F('1234567'), '_.2f', '1_234_567.00'), + (F('12345678'), '_.2f', '12_345_678.00'), + # Thousands and zero-padding + (F('1234.5678'), '07,.2f', '1,234.57'), + (F('1234.5678'), '08,.2f', '1,234.57'), + (F('1234.5678'), '09,.2f', '01,234.57'), + (F('1234.5678'), '010,.2f', '001,234.57'), + (F('1234.5678'), '011,.2f', '0,001,234.57'), + (F('1234.5678'), '012,.2f', '0,001,234.57'), + (F('1234.5678'), '013,.2f', '00,001,234.57'), + (F('1234.5678'), '014,.2f', '000,001,234.57'), + (F('1234.5678'), '015,.2f', '0,000,001,234.57'), + (F('1234.5678'), '016,.2f', '0,000,001,234.57'), + (F('-1234.5678'), '07,.2f', '-1,234.57'), + (F('-1234.5678'), '08,.2f', '-1,234.57'), + (F('-1234.5678'), '09,.2f', '-1,234.57'), + (F('-1234.5678'), '010,.2f', '-01,234.57'), + (F('-1234.5678'), '011,.2f', '-001,234.57'), + (F('-1234.5678'), '012,.2f', '-0,001,234.57'), + (F('-1234.5678'), '013,.2f', '-0,001,234.57'), + (F('-1234.5678'), '014,.2f', '-00,001,234.57'), + (F('-1234.5678'), '015,.2f', '-000,001,234.57'), + (F('-1234.5678'), '016,.2f', '-0,000,001,234.57'), + # Corner case: no decimal point + (F('-1234.5678'), '06,.0f', '-1,235'), + (F('-1234.5678'), '07,.0f', '-01,235'), + (F('-1234.5678'), '08,.0f', '-001,235'), + (F('-1234.5678'), '09,.0f', '-0,001,235'), + # Corner-case - zero-padding specified through fill and align + # instead of the zero-pad character - in this case, treat '0' as a + # regular fill character and don't attempt to insert commas into + # the filled portion. This differs from the int and float + # behaviour. + (F('1234.5678'), '0=12,.2f', '00001,234.57'), + # Corner case where it's not clear whether the '0' indicates zero + # padding or gives the minimum width, but there's still an obvious + # answer to give. We want this to work in case the minimum width + # is being inserted programmatically: spec = f'{width}.2f'. + (F('12.34'), '0.2f', '12.34'), + (F('12.34'), 'X>0.2f', '12.34'), + ] + for fraction, spec, expected in testcases: + with self.subTest(fraction=fraction, spec=spec): + self.assertEqual(format(fraction, spec), expected) + + def test_invalid_formats(self): + fraction = F(2, 3) + with self.assertRaises(TypeError): + format(fraction, None) + + invalid_specs = [ + "Q6f", # regression test + # illegal to use fill or alignment when zero padding + "X>010f", + "X<010f", + "X^010f", + "X=010f", + "0>010f", + "0<010f", + "0^010f", + "0=010f", + ">010f", + "<010f", + "^010f", + "=010f", + ] + for spec in invalid_specs: + with self.subTest(spec=spec): + with self.assertRaises(ValueError): + format(fraction, spec) + if __name__ == '__main__': unittest.main() From e9db69753c8793a36c9459b462c9e4cb4cf0a403 Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Sat, 3 Dec 2022 19:57:16 +0000 Subject: [PATCH 02/31] Add support for % and F format specifiers --- Lib/fractions.py | 14 ++++++++------ Lib/test/test_fractions.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/Lib/fractions.py b/Lib/fractions.py index 1d64910724d87d..1534af8c34880d 100644 --- a/Lib/fractions.py +++ b/Lib/fractions.py @@ -329,8 +329,8 @@ def __format__(self, format_spec, /): (?P\d+)? (?P[,_])? (?:\.(?P\d+))? - f - """, re.DOTALL | re.VERBOSE).fullmatch + (?P[f%]) + """, re.DOTALL | re.IGNORECASE | re.VERBOSE).fullmatch # Validate and parse the format specifier. match = FORMAT_SPEC_MATCHER(format_spec) @@ -350,16 +350,17 @@ def __format__(self, format_spec, /): fill = match["fill"] or " " align = match["align"] or ">" pos_sign = "" if match["sign"] == "-" else match["sign"] - neg_sign = "-" alternate_form = bool(match["alt"]) zeropad = bool(match["zeropad"]) minimumwidth = int(match["minimumwidth"] or "0") thousands_sep = match["thousands_sep"] precision = int(match["precision"] or "6") + specifier_type = match["specifier_type"] # Get sign and output digits for the target number negative = self < 0 - digits = str(round(abs(self) * 10**precision)) + shift = precision + 2 if specifier_type == "%" else precision + digits = str(round(abs(self) * 10**shift)) # Assemble the output: before padding, it has the form # f"{sign}{leading}{trailing}", where `leading` includes thousands @@ -367,9 +368,10 @@ def __format__(self, format_spec, /): # separator where appropriate. digits = digits.zfill(precision + 1) dot_pos = len(digits) - precision - sign = neg_sign if negative else pos_sign + sign = "-" if negative else pos_sign separator = "." if precision or alternate_form else "" - trailing = separator + digits[dot_pos:] + percent = "%" if specifier_type == "%" else "" + trailing = separator + digits[dot_pos:] + percent leading = digits[:dot_pos] # Do zero padding if required. diff --git a/Lib/test/test_fractions.py b/Lib/test/test_fractions.py index f448e85a68ae74..d3598209421104 100644 --- a/Lib/test/test_fractions.py +++ b/Lib/test/test_fractions.py @@ -976,6 +976,21 @@ def test_format(self): # is being inserted programmatically: spec = f'{width}.2f'. (F('12.34'), '0.2f', '12.34'), (F('12.34'), 'X>0.2f', '12.34'), + # "F" should work identically to "f" + (F(22, 7), '.5F', '3.14286'), + # %-specifier + (F(22, 7), '.2%', '314.29%'), + (F(1, 7), '.2%', '14.29%'), + (F(1, 70), '.2%', '1.43%'), + (F(1, 700), '.2%', '0.14%'), + (F(1, 7000), '.2%', '0.01%'), + (F(1, 70000), '.2%', '0.00%'), + (F(1, 7), '.0%', '14%'), + (F(1, 7), '#.0%', '14.%'), + (F(100, 7), ',.2%', '1,428.57%'), + (F(22, 7), '7.2%', '314.29%'), + (F(22, 7), '8.2%', ' 314.29%'), + (F(22, 7), '08.2%', '0314.29%'), ] for fraction, spec, expected in testcases: with self.subTest(fraction=fraction, spec=spec): From 6491b046ce875d298c0e13adbf33460cb217560e Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Sun, 4 Dec 2022 09:27:25 +0000 Subject: [PATCH 03/31] Add support for 'z' flag --- Lib/fractions.py | 8 +++++--- Lib/test/test_fractions.py | 9 +++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Lib/fractions.py b/Lib/fractions.py index 1534af8c34880d..0713c89bdfdca6 100644 --- a/Lib/fractions.py +++ b/Lib/fractions.py @@ -324,6 +324,7 @@ def __format__(self, format_spec, /): (?P[<>=^]) )? (?P[-+ ]?) + (?Pz)? (?P\#)? (?P0(?=\d))? (?P\d+)? @@ -350,6 +351,7 @@ def __format__(self, format_spec, /): fill = match["fill"] or " " align = match["align"] or ">" pos_sign = "" if match["sign"] == "-" else match["sign"] + neg_zero_ok = not match["no_neg_zero"] alternate_form = bool(match["alt"]) zeropad = bool(match["zeropad"]) minimumwidth = int(match["minimumwidth"] or "0") @@ -360,15 +362,15 @@ def __format__(self, format_spec, /): # Get sign and output digits for the target number negative = self < 0 shift = precision + 2 if specifier_type == "%" else precision - digits = str(round(abs(self) * 10**shift)) + significand = round(abs(self) * 10**shift) # Assemble the output: before padding, it has the form # f"{sign}{leading}{trailing}", where `leading` includes thousands # separators if necessary, and `trailing` includes the decimal # separator where appropriate. - digits = digits.zfill(precision + 1) + digits = str(significand).zfill(precision + 1) dot_pos = len(digits) - precision - sign = "-" if negative else pos_sign + sign = "-" if negative and (significand or neg_zero_ok) else pos_sign separator = "." if precision or alternate_form else "" percent = "%" if specifier_type == "%" else "" trailing = separator + digits[dot_pos:] + percent diff --git a/Lib/test/test_fractions.py b/Lib/test/test_fractions.py index d3598209421104..77c3e9eaeb0ed8 100644 --- a/Lib/test/test_fractions.py +++ b/Lib/test/test_fractions.py @@ -976,6 +976,15 @@ def test_format(self): # is being inserted programmatically: spec = f'{width}.2f'. (F('12.34'), '0.2f', '12.34'), (F('12.34'), 'X>0.2f', '12.34'), + # z flag for suppressing negative zeros + (F('-0.001'), 'z.2f', '0.00'), + (F('-0.001'), '-z.2f', '0.00'), + (F('-0.001'), '+z.2f', '+0.00'), + (F('-0.001'), ' z.2f', ' 0.00'), + (F('0.001'), 'z.2f', '0.00'), + (F('0.001'), '-z.2f', '0.00'), + (F('0.001'), '+z.2f', '+0.00'), + (F('0.001'), ' z.2f', ' 0.00'), # "F" should work identically to "f" (F(22, 7), '.5F', '3.14286'), # %-specifier From 455146856395dc0925d7b206ca3cd8fd0cc1bd66 Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Sun, 4 Dec 2022 09:37:01 +0000 Subject: [PATCH 04/31] Tidy up of business logic --- Lib/fractions.py | 36 +++++++++++++++--------------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/Lib/fractions.py b/Lib/fractions.py index 0713c89bdfdca6..da28c9a5afe152 100644 --- a/Lib/fractions.py +++ b/Lib/fractions.py @@ -330,7 +330,7 @@ def __format__(self, format_spec, /): (?P\d+)? (?P[,_])? (?:\.(?P\d+))? - (?P[f%]) + (?P[f%]) """, re.DOTALL | re.IGNORECASE | re.VERBOSE).fullmatch # Validate and parse the format specifier. @@ -357,11 +357,11 @@ def __format__(self, format_spec, /): minimumwidth = int(match["minimumwidth"] or "0") thousands_sep = match["thousands_sep"] precision = int(match["precision"] or "6") - specifier_type = match["specifier_type"] + presentation_type = match["presentation_type"] # Get sign and output digits for the target number negative = self < 0 - shift = precision + 2 if specifier_type == "%" else precision + shift = precision + 2 if presentation_type == "%" else precision significand = round(abs(self) * 10**shift) # Assemble the output: before padding, it has the form @@ -372,7 +372,7 @@ def __format__(self, format_spec, /): dot_pos = len(digits) - precision sign = "-" if negative and (significand or neg_zero_ok) else pos_sign separator = "." if precision or alternate_form else "" - percent = "%" if specifier_type == "%" else "" + percent = "%" if presentation_type == "%" else "" trailing = separator + digits[dot_pos:] + percent leading = digits[:dot_pos] @@ -395,23 +395,17 @@ def __format__(self, format_spec, /): after_sign = leading + trailing - # Pad if a minimum width was given and we haven't already zero padded. - if zeropad or minimumwidth is None: - result = sign + after_sign - else: - padding = fill * (minimumwidth - len(sign) - len(after_sign)) - if align == ">": - result = padding + sign + after_sign - elif align == "<": - result = sign + after_sign + padding - elif align == "=": - result = sign + padding + after_sign - else: - # Centered, with a leftwards bias when padding length is odd. - assert align == "^" - half = len(padding)//2 - result = padding[:half] + sign + after_sign + padding[half:] - return result + # Pad if necessary and return. + padding = fill * (minimumwidth - len(sign) - len(after_sign)) + if align == ">": + return padding + sign + after_sign + elif align == "<": + return sign + after_sign + padding + elif align == "^": + half = len(padding)//2 + return padding[:half] + sign + after_sign + padding[half:] + else: # align == "=" + return sign + padding + after_sign def _operator_fallbacks(monomorphic_operator, fallback_operator): """Generates forward and reverse operators given a purely-rational From 43d34fc38d47a3aba7f25ee33820c8f07a11ddd8 Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Sun, 4 Dec 2022 12:01:02 +0000 Subject: [PATCH 05/31] Add support for 'e' presentation type --- Lib/fractions.py | 83 +++++++++++++++++++++++++------------- Lib/test/test_fractions.py | 72 ++++++++++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 29 deletions(-) diff --git a/Lib/fractions.py b/Lib/fractions.py index da28c9a5afe152..a8c2d30084f4a3 100644 --- a/Lib/fractions.py +++ b/Lib/fractions.py @@ -68,6 +68,23 @@ def _hash_algorithm(numerator, denominator): \s*\Z # and optional whitespace to finish """, re.VERBOSE | re.IGNORECASE) +# Pattern for matching format specification; only supports 'e', 'E', 'f', 'F' +# and '%' presentation types. +_FORMAT_SPECIFICATION_MATCHER = re.compile(r""" + (?: + (?P.)? + (?P[<>=^]) + )? + (?P[-+ ]?) + (?Pz)? + (?P\#)? + (?P0(?=\d))? + (?P\d+)? + (?P[,_])? + (?:\.(?P\d+))? + (?P[ef%]) +""", re.DOTALL | re.IGNORECASE | re.VERBOSE).fullmatch + class Fraction(numbers.Rational): """This class implements rational numbers. @@ -317,24 +334,8 @@ def __format__(self, format_spec, /): if not format_spec: return str(self) - # Pattern matcher for the format spec; only supports "f" so far - FORMAT_SPEC_MATCHER = re.compile(r""" - (?: - (?P.)? - (?P[<>=^]) - )? - (?P[-+ ]?) - (?Pz)? - (?P\#)? - (?P0(?=\d))? - (?P\d+)? - (?P[,_])? - (?:\.(?P\d+))? - (?P[f%]) - """, re.DOTALL | re.IGNORECASE | re.VERBOSE).fullmatch - # Validate and parse the format specifier. - match = FORMAT_SPEC_MATCHER(format_spec) + match = _FORMAT_SPECIFICATION_MATCHER(format_spec) if match is None: raise ValueError( f"Invalid format specifier {format_spec!r} " @@ -361,8 +362,37 @@ def __format__(self, format_spec, /): # Get sign and output digits for the target number negative = self < 0 - shift = precision + 2 if presentation_type == "%" else precision - significand = round(abs(self) * 10**shift) + self = abs(self) + if presentation_type == "f" or presentation_type == "F": + suffix = "" + significand = round(self * 10**precision) + elif presentation_type == "%": + suffix = "%" + significand = round(self * 10**(precision + 2)) + elif presentation_type == "e" or presentation_type == "E": + if not self: + significand = 0 + exponent = 0 + else: + # Find integer 'exponent' satisfying the constraint + # 10**exponent <= self <= 10**(exponent + 1) + # (Either possibility for exponent is fine in the case + # where 'self' is an exact power of 10.) + str_n, str_d = str(self.numerator), str(self.denominator) + exponent = len(str_n) - len(str_d) - (str_n < str_d) + + # Compute the necessary digits. + if precision >= exponent: + significand = round(self * 10**(precision - exponent)) + else: + significand = round(self / 10**(exponent - precision)) + if len(str(significand)) == precision + 2: + # Can only happen when significand is a power of 10. + assert significand % 10 == 0 + significand //= 10 + exponent += 1 + assert len(str(significand)) == precision + 1 + suffix = f"{presentation_type}{exponent:+03d}" # Assemble the output: before padding, it has the form # f"{sign}{leading}{trailing}", where `leading` includes thousands @@ -372,8 +402,7 @@ def __format__(self, format_spec, /): dot_pos = len(digits) - precision sign = "-" if negative and (significand or neg_zero_ok) else pos_sign separator = "." if precision or alternate_form else "" - percent = "%" if presentation_type == "%" else "" - trailing = separator + digits[dot_pos:] + percent + trailing = separator + digits[dot_pos:] + suffix leading = digits[:dot_pos] # Do zero padding if required. @@ -393,19 +422,19 @@ def __format__(self, format_spec, /): for pos in range(first_pos, len(leading), 3) ) - after_sign = leading + trailing + body = leading + trailing # Pad if necessary and return. - padding = fill * (minimumwidth - len(sign) - len(after_sign)) + padding = fill * (minimumwidth - len(sign) - len(body)) if align == ">": - return padding + sign + after_sign + return padding + sign + body elif align == "<": - return sign + after_sign + padding + return sign + body + padding elif align == "^": half = len(padding)//2 - return padding[:half] + sign + after_sign + padding[half:] + return padding[:half] + sign + body + padding[half:] else: # align == "=" - return sign + padding + after_sign + return sign + padding + body def _operator_fallbacks(monomorphic_operator, fallback_operator): """Generates forward and reverse operators given a purely-rational diff --git a/Lib/test/test_fractions.py b/Lib/test/test_fractions.py index 77c3e9eaeb0ed8..21a978d661bca7 100644 --- a/Lib/test/test_fractions.py +++ b/Lib/test/test_fractions.py @@ -830,12 +830,19 @@ def denominator(self): self.assertEqual(type(f.numerator), myint) self.assertEqual(type(f.denominator), myint) - def test_format(self): + def test_format_no_presentation_type(self): # Triples (fraction, specification, expected_result) testcases = [ - # Case inherited from object - equivalent to str() (F(1, 3), '', '1/3'), (F(-1, 3), '', '-1/3'), + ] + for fraction, spec, expected in testcases: + with self.subTest(fraction=fraction, spec=spec): + self.assertEqual(format(fraction, spec), expected) + + def test_format_f_presentation_type(self): + # Triples (fraction, specification, expected_result) + testcases = [ # Simple .f formatting (F(0, 1), '.2f', '0.00'), (F(1, 3), '.2f', '0.33'), @@ -1005,6 +1012,63 @@ def test_format(self): with self.subTest(fraction=fraction, spec=spec): self.assertEqual(format(fraction, spec), expected) + def test_format_e_presentation_type(self): + # Triples (fraction, specification, expected_result) + testcases = [ + (F(2, 3), '.6e', '6.666667e-01'), + (F(3, 2), '.6e', '1.500000e+00'), + (F(2, 13), '.6e', '1.538462e-01'), + (F(2, 23), '.6e', '8.695652e-02'), + (F(2, 33), '.6e', '6.060606e-02'), + (F(13, 2), '.6e', '6.500000e+00'), + (F(20, 2), '.6e', '1.000000e+01'), + (F(23, 2), '.6e', '1.150000e+01'), + (F(33, 2), '.6e', '1.650000e+01'), + (F(2, 3), '.6e', '6.666667e-01'), + (F(3, 2), '.6e', '1.500000e+00'), + # Zero + (F(0), '.3e', '0.000e+00'), + # Powers of 10, to exercise the log10 boundary logic + (F(1, 1000), '.3e', '1.000e-03'), + (F(1, 100), '.3e', '1.000e-02'), + (F(1, 10), '.3e', '1.000e-01'), + (F(1, 1), '.3e', '1.000e+00'), + (F(10), '.3e', '1.000e+01'), + (F(100), '.3e', '1.000e+02'), + (F(1000), '.3e', '1.000e+03'), + # Boundary where we round up to the next power of 10 + (F('99.999994999999'), '.6e', '9.999999e+01'), + (F('99.999995'), '.6e', '1.000000e+02'), + (F('99.999995000001'), '.6e', '1.000000e+02'), + # Negatives + (F(-2, 3), '.6e', '-6.666667e-01'), + (F(-3, 2), '.6e', '-1.500000e+00'), + (F(-100), '.6e', '-1.000000e+02'), + # Large and small + (F('1e1000'), '.3e', '1.000e+1000'), + (F('1e-1000'), '.3e', '1.000e-1000'), + # Using 'E' instead of 'e' should give us a capital 'E' + (F(2, 3), '.6E', '6.666667E-01'), + # Tiny precision + (F(2, 3), '.1e', '6.7e-01'), + (F('0.995'), '.0e', '1e+00'), + # Default precision is 6 + (F(22, 7), 'e', '3.142857e+00'), + # Alternate form forces a decimal point + (F('0.995'), '#.0e', '1.e+00'), + # Check that padding takes the exponent into account. + (F(22, 7), '11.6e', '3.142857e+00'), + (F(22, 7), '12.6e', '3.142857e+00'), + (F(22, 7), '13.6e', ' 3.142857e+00'), + # Legal to specify a thousands separator, but it'll have no effect + (F('1234567.123456'), ',.5e', '1.23457e+06'), + # Same with z flag: legal, but useless + (F(-1, 7**100), 'z.6e', '-3.091690e-85'), + ] + for fraction, spec, expected in testcases: + with self.subTest(fraction=fraction, spec=spec): + self.assertEqual(format(fraction, spec), expected) + def test_invalid_formats(self): fraction = F(2, 3) with self.assertRaises(TypeError): @@ -1025,6 +1089,10 @@ def test_invalid_formats(self): "<010f", "^010f", "=010f", + "=010e", + # Missing precision + ".f", + ".e", ] for spec in invalid_specs: with self.subTest(spec=spec): From f8dfcb96ec2399154a3cf37518e67062e0d21c5a Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Sun, 4 Dec 2022 20:20:35 +0000 Subject: [PATCH 06/31] Add support for 'g' presentation type; tidy --- Lib/fractions.py | 125 +++++++++++++++++---------- Lib/test/test_fractions.py | 167 +++++++++++++++++++++++++------------ 2 files changed, 194 insertions(+), 98 deletions(-) diff --git a/Lib/fractions.py b/Lib/fractions.py index a8c2d30084f4a3..70a2a9d19b485f 100644 --- a/Lib/fractions.py +++ b/Lib/fractions.py @@ -82,7 +82,7 @@ def _hash_algorithm(numerator, denominator): (?P\d+)? (?P[,_])? (?:\.(?P\d+))? - (?P[ef%]) + (?P[efg%]) """, re.DOTALL | re.IGNORECASE | re.VERBOSE).fullmatch @@ -327,6 +327,35 @@ def __str__(self): else: return '%s/%s' % (self._numerator, self._denominator) + def _round_to_sig_figs(self, figures): + """Round a positive fraction to a given number of significant figures. + + Returns a pair (significand, exponent) of integers such that + significand * 10**exponent gives a rounded approximation to self, and + significand lies in the range 10**(figures - 1) <= significand < + 10**figures. + """ + if not (self > 0 and figures > 0): + raise ValueError("Expected self and figures to be positive") + + # Find integer m satisfying 10**(m - 1) <= self <= 10**m. + str_n, str_d = str(self.numerator), str(self.denominator) + m = len(str_n) - len(str_d) + (str_d <= str_n) + + # Find best approximation significand * 10**exponent to self, with + # 10**(figures - 1) <= significand <= 10**figures. + exponent = m - figures + significand = round( + self / 10**exponent if exponent >= 0 else self * 10**-exponent + ) + + # Adjust in the case where significand == 10**figures. + if len(str(significand)) == figures + 1: + significand //= 10 + exponent += 1 + + return significand, exponent + def __format__(self, format_spec, /): """Format this fraction according to the given format specification.""" @@ -348,62 +377,67 @@ def __format__(self, format_spec, /): f"for object of type {type(self).__name__!r}; " "can't use explicit alignment when zero-padding" ) - else: - fill = match["fill"] or " " - align = match["align"] or ">" - pos_sign = "" if match["sign"] == "-" else match["sign"] - neg_zero_ok = not match["no_neg_zero"] - alternate_form = bool(match["alt"]) - zeropad = bool(match["zeropad"]) - minimumwidth = int(match["minimumwidth"] or "0") - thousands_sep = match["thousands_sep"] - precision = int(match["precision"] or "6") - presentation_type = match["presentation_type"] - - # Get sign and output digits for the target number + + fill = match["fill"] or " " + align = match["align"] or ">" + pos_sign = "" if match["sign"] == "-" else match["sign"] + neg_zero_ok = not match["no_neg_zero"] + alternate_form = bool(match["alt"]) + zeropad = bool(match["zeropad"]) + minimumwidth = int(match["minimumwidth"] or "0") + thousands_sep = match["thousands_sep"] + precision = int(match["precision"] or "6") + presentation_type = match["presentation_type"] + trim_zeros = presentation_type in "gG" and not alternate_form + trim_dot = not alternate_form + exponent_indicator = "E" if presentation_type in "EFG" else "e" + + # Record sign, then work with absolute value. negative = self < 0 self = abs(self) + + # Round to get the digits we need; also compute the suffix. if presentation_type == "f" or presentation_type == "F": - suffix = "" significand = round(self * 10**precision) + point_pos = precision + suffix = "" elif presentation_type == "%": - suffix = "%" significand = round(self * 10**(precision + 2)) - elif presentation_type == "e" or presentation_type == "E": - if not self: - significand = 0 - exponent = 0 + point_pos = precision + suffix = "%" + elif presentation_type in "eEgG": + if presentation_type in "gG": + figures = max(precision, 1) else: - # Find integer 'exponent' satisfying the constraint - # 10**exponent <= self <= 10**(exponent + 1) - # (Either possibility for exponent is fine in the case - # where 'self' is an exact power of 10.) - str_n, str_d = str(self.numerator), str(self.denominator) - exponent = len(str_n) - len(str_d) - (str_n < str_d) - - # Compute the necessary digits. - if precision >= exponent: - significand = round(self * 10**(precision - exponent)) - else: - significand = round(self / 10**(exponent - precision)) - if len(str(significand)) == precision + 2: - # Can only happen when significand is a power of 10. - assert significand % 10 == 0 - significand //= 10 - exponent += 1 - assert len(str(significand)) == precision + 1 - suffix = f"{presentation_type}{exponent:+03d}" + figures = precision + 1 + if self: + significand, exponent = self._round_to_sig_figs(figures) + else: + significand, exponent = 0, 1 - figures + if presentation_type in "gG" and -4 - figures < exponent <= 0: + point_pos = -exponent + suffix = "" + else: + point_pos = figures - 1 + suffix = f"{exponent_indicator}{exponent + point_pos:+03d}" + else: + # It shouldn't be possible to get here. + raise ValueError( + f"unknown presentation type {presentation_type!r}" + ) # Assemble the output: before padding, it has the form # f"{sign}{leading}{trailing}", where `leading` includes thousands # separators if necessary, and `trailing` includes the decimal # separator where appropriate. - digits = str(significand).zfill(precision + 1) - dot_pos = len(digits) - precision + digits = f"{significand:0{point_pos + 1}d}" sign = "-" if negative and (significand or neg_zero_ok) else pos_sign - separator = "." if precision or alternate_form else "" - trailing = separator + digits[dot_pos:] + suffix - leading = digits[:dot_pos] + leading = digits[:len(digits) - point_pos] + frac_part = digits[len(digits) - point_pos:] + if trim_zeros: + frac_part = frac_part.rstrip("0") + separator = "" if trim_dot and not frac_part else "." + trailing = separator + frac_part + suffix # Do zero padding if required. if zeropad: @@ -422,9 +456,8 @@ def __format__(self, format_spec, /): for pos in range(first_pos, len(leading), 3) ) - body = leading + trailing - # Pad if necessary and return. + body = leading + trailing padding = fill * (minimumwidth - len(sign) - len(body)) if align == ">": return padding + sign + body diff --git a/Lib/test/test_fractions.py b/Lib/test/test_fractions.py index 21a978d661bca7..efb1eb23dfac38 100644 --- a/Lib/test/test_fractions.py +++ b/Lib/test/test_fractions.py @@ -840,6 +840,63 @@ def test_format_no_presentation_type(self): with self.subTest(fraction=fraction, spec=spec): self.assertEqual(format(fraction, spec), expected) + def test_format_e_presentation_type(self): + # Triples (fraction, specification, expected_result) + testcases = [ + (F(2, 3), '.6e', '6.666667e-01'), + (F(3, 2), '.6e', '1.500000e+00'), + (F(2, 13), '.6e', '1.538462e-01'), + (F(2, 23), '.6e', '8.695652e-02'), + (F(2, 33), '.6e', '6.060606e-02'), + (F(13, 2), '.6e', '6.500000e+00'), + (F(20, 2), '.6e', '1.000000e+01'), + (F(23, 2), '.6e', '1.150000e+01'), + (F(33, 2), '.6e', '1.650000e+01'), + (F(2, 3), '.6e', '6.666667e-01'), + (F(3, 2), '.6e', '1.500000e+00'), + # Zero + (F(0), '.3e', '0.000e+00'), + # Powers of 10, to exercise the log10 boundary logic + (F(1, 1000), '.3e', '1.000e-03'), + (F(1, 100), '.3e', '1.000e-02'), + (F(1, 10), '.3e', '1.000e-01'), + (F(1, 1), '.3e', '1.000e+00'), + (F(10), '.3e', '1.000e+01'), + (F(100), '.3e', '1.000e+02'), + (F(1000), '.3e', '1.000e+03'), + # Boundary where we round up to the next power of 10 + (F('99.999994999999'), '.6e', '9.999999e+01'), + (F('99.999995'), '.6e', '1.000000e+02'), + (F('99.999995000001'), '.6e', '1.000000e+02'), + # Negatives + (F(-2, 3), '.6e', '-6.666667e-01'), + (F(-3, 2), '.6e', '-1.500000e+00'), + (F(-100), '.6e', '-1.000000e+02'), + # Large and small + (F('1e1000'), '.3e', '1.000e+1000'), + (F('1e-1000'), '.3e', '1.000e-1000'), + # Using 'E' instead of 'e' should give us a capital 'E' + (F(2, 3), '.6E', '6.666667E-01'), + # Tiny precision + (F(2, 3), '.1e', '6.7e-01'), + (F('0.995'), '.0e', '1e+00'), + # Default precision is 6 + (F(22, 7), 'e', '3.142857e+00'), + # Alternate form forces a decimal point + (F('0.995'), '#.0e', '1.e+00'), + # Check that padding takes the exponent into account. + (F(22, 7), '11.6e', '3.142857e+00'), + (F(22, 7), '12.6e', '3.142857e+00'), + (F(22, 7), '13.6e', ' 3.142857e+00'), + # Legal to specify a thousands separator, but it'll have no effect + (F('1234567.123456'), ',.5e', '1.23457e+06'), + # Same with z flag: legal, but useless + (F(-1, 7**100), 'z.6e', '-3.091690e-85'), + ] + for fraction, spec, expected in testcases: + with self.subTest(fraction=fraction, spec=spec): + self.assertEqual(format(fraction, spec), expected) + def test_format_f_presentation_type(self): # Triples (fraction, specification, expected_result) testcases = [ @@ -1012,58 +1069,60 @@ def test_format_f_presentation_type(self): with self.subTest(fraction=fraction, spec=spec): self.assertEqual(format(fraction, spec), expected) - def test_format_e_presentation_type(self): + def test_format_g_presentation_type(self): # Triples (fraction, specification, expected_result) testcases = [ - (F(2, 3), '.6e', '6.666667e-01'), - (F(3, 2), '.6e', '1.500000e+00'), - (F(2, 13), '.6e', '1.538462e-01'), - (F(2, 23), '.6e', '8.695652e-02'), - (F(2, 33), '.6e', '6.060606e-02'), - (F(13, 2), '.6e', '6.500000e+00'), - (F(20, 2), '.6e', '1.000000e+01'), - (F(23, 2), '.6e', '1.150000e+01'), - (F(33, 2), '.6e', '1.650000e+01'), - (F(2, 3), '.6e', '6.666667e-01'), - (F(3, 2), '.6e', '1.500000e+00'), - # Zero - (F(0), '.3e', '0.000e+00'), - # Powers of 10, to exercise the log10 boundary logic - (F(1, 1000), '.3e', '1.000e-03'), - (F(1, 100), '.3e', '1.000e-02'), - (F(1, 10), '.3e', '1.000e-01'), - (F(1, 1), '.3e', '1.000e+00'), - (F(10), '.3e', '1.000e+01'), - (F(100), '.3e', '1.000e+02'), - (F(1000), '.3e', '1.000e+03'), - # Boundary where we round up to the next power of 10 - (F('99.999994999999'), '.6e', '9.999999e+01'), - (F('99.999995'), '.6e', '1.000000e+02'), - (F('99.999995000001'), '.6e', '1.000000e+02'), - # Negatives - (F(-2, 3), '.6e', '-6.666667e-01'), - (F(-3, 2), '.6e', '-1.500000e+00'), - (F(-100), '.6e', '-1.000000e+02'), - # Large and small - (F('1e1000'), '.3e', '1.000e+1000'), - (F('1e-1000'), '.3e', '1.000e-1000'), - # Using 'E' instead of 'e' should give us a capital 'E' - (F(2, 3), '.6E', '6.666667E-01'), - # Tiny precision - (F(2, 3), '.1e', '6.7e-01'), - (F('0.995'), '.0e', '1e+00'), - # Default precision is 6 - (F(22, 7), 'e', '3.142857e+00'), - # Alternate form forces a decimal point - (F('0.995'), '#.0e', '1.e+00'), - # Check that padding takes the exponent into account. - (F(22, 7), '11.6e', '3.142857e+00'), - (F(22, 7), '12.6e', '3.142857e+00'), - (F(22, 7), '13.6e', ' 3.142857e+00'), - # Legal to specify a thousands separator, but it'll have no effect - (F('1234567.123456'), ',.5e', '1.23457e+06'), - # Same with z flag: legal, but useless - (F(-1, 7**100), 'z.6e', '-3.091690e-85'), + (F('0.000012345678'), '.6g', '1.23457e-05'), + (F('0.00012345678'), '.6g', '0.000123457'), + (F('0.0012345678'), '.6g', '0.00123457'), + (F('0.012345678'), '.6g', '0.0123457'), + (F('0.12345678'), '.6g', '0.123457'), + (F('1.2345678'), '.6g', '1.23457'), + (F('12.345678'), '.6g', '12.3457'), + (F('123.45678'), '.6g', '123.457'), + (F('1234.5678'), '.6g', '1234.57'), + (F('12345.678'), '.6g', '12345.7'), + (F('123456.78'), '.6g', '123457'), + (F('1234567.8'), '.6g', '1.23457e+06'), + # Rounding up cases + (F('9.99999e+2'), '.4g', '1000'), + (F('9.99999e-8'), '.4g', '1e-07'), + (F('9.99999e+8'), '.4g', '1e+09'), + # Trailing zeros and decimal point suppressed by default ... + (F(0), '.6g', '0'), + (F('123.400'), '.6g', '123.4'), + (F('123.000'), '.6g', '123'), + (F('120.000'), '.6g', '120'), + (F('12000000'), '.6g', '1.2e+07'), + # ... but not when alternate form is in effect + (F(0), '#.6g', '0.00000'), + (F('123.400'), '#.6g', '123.400'), + (F('123.000'), '#.6g', '123.000'), + (F('120.000'), '#.6g', '120.000'), + (F('12000000'), '#.6g', '1.20000e+07'), + # 'G' format (uses 'E' instead of 'e' for the exponent indicator) + (F('123.45678'), '.6G', '123.457'), + (F('1234567.8'), '.6G', '1.23457E+06'), + # Default precision is 6 significant figures + (F('3.1415926535'), 'g', '3.14159'), + # Precision 0 is treated the same as precision 1. + (F('0.000031415'), '.0g', '3e-05'), + (F('0.00031415'), '.0g', '0.0003'), + (F('0.31415'), '.0g', '0.3'), + (F('3.1415'), '.0g', '3'), + (F('3.1415'), '#.0g', '3.'), + (F('31.415'), '.0g', '3e+01'), + (F('31.415'), '#.0g', '3.e+01'), + (F('0.000031415'), '.1g', '3e-05'), + (F('0.00031415'), '.1g', '0.0003'), + (F('0.31415'), '.1g', '0.3'), + (F('3.1415'), '.1g', '3'), + (F('3.1415'), '#.1g', '3.'), + (F('31.415'), '.1g', '3e+01'), + # Thousands separator + (F(2**64), '_.25g', '18_446_744_073_709_551_616'), + # As with 'e' format, z flag is legal, but has no effect + (F(-1, 7**100), 'zg', '-3.09169e-85'), ] for fraction, spec, expected in testcases: with self.subTest(fraction=fraction, spec=spec): @@ -1088,11 +1147,15 @@ def test_invalid_formats(self): ">010f", "<010f", "^010f", - "=010f", "=010e", + "=010f", + "=010g", + "=010%", # Missing precision - ".f", ".e", + ".f", + ".g", + ".%", ] for spec in invalid_specs: with self.subTest(spec=spec): From 6bfbc6c10087b6604e884bf1f10113247ce45f76 Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Sat, 10 Dec 2022 14:00:55 +0000 Subject: [PATCH 07/31] Tidying --- Lib/fractions.py | 180 ++++++++++++++++++++++--------------- Lib/test/test_fractions.py | 29 ++++-- 2 files changed, 127 insertions(+), 82 deletions(-) diff --git a/Lib/fractions.py b/Lib/fractions.py index 70a2a9d19b485f..9b216e59e9f683 100644 --- a/Lib/fractions.py +++ b/Lib/fractions.py @@ -68,8 +68,76 @@ def _hash_algorithm(numerator, denominator): \s*\Z # and optional whitespace to finish """, re.VERBOSE | re.IGNORECASE) -# Pattern for matching format specification; only supports 'e', 'E', 'f', 'F' -# and '%' presentation types. + +# Helpers for formatting + +def _round_to_exponent(n, d, exponent, no_neg_zero=False): + """Round a rational number to an integer multiple of a power of 10. + + Rounds the rational number n/d to the nearest integer multiple of + 10**exponent using the round-ties-to-even rule, and returns a + pair (sign, significand) representing the rounded value + (-1)**sign * significand. + + d must be positive, but n and d need not be relatively prime. + + If no_neg_zero is true, then the returned sign will always be False + for a zero result. Otherwise, the sign is based on the sign of the input. + """ + if exponent >= 0: + d *= 10**exponent + else: + n *= 10**-exponent + + # The divmod quotient rounds ties towards positive infinity; we then adjust + # as needed for round-ties-to-even behaviour. + q, r = divmod(n + (d >> 1), d) + if r == 0 and d & 1 == 0: # Tie + q &= -2 + + sign = q < 0 if no_neg_zero else n < 0 + return sign, abs(q) + + +def _round_to_figures(n, d, figures): + """Round a rational number to a given number of significant figures. + + Rounds the rational number n/d to the given number of significant figures + using the round-ties-to-even rule, and returns a triple (sign, significand, + exponent) representing the rounded value (-1)**sign * significand * + 10**exponent. + + d must be positive, but n and d need not be relatively prime. + figures must be positive. + + In the special case where n = 0, returns an exponent of 1 - figures, for + compatibility with formatting; the significand will be zero. Otherwise, + the significand satisfies 10**(figures - 1) <= significand < 10**figures. + """ + # Find integer m satisfying 10**(m - 1) <= abs(self) <= 10**m if self + # is nonzero, with m = 1 if self = 0. (The latter choice is a little + # arbitrary, but gives the "right" results when formatting zero.) + if n == 0: + m = 1 + else: + str_n, str_d = str(abs(n)), str(d) + m = len(str_n) - len(str_d) + (str_d <= str_n) + + # Round to a multiple of 10**(m - figures). The result will satisfy either + # significand == 0 or 10**(figures - 1) <= significand <= 10**figures. + exponent = m - figures + sign, significand = _round_to_exponent(n, d, exponent) + + # Adjust in the case where significand == 10**figures. + if len(str(significand)) == figures + 1: + significand //= 10 + exponent += 1 + + return sign, significand, exponent + + +# Pattern for matching format specification; supports 'e', 'E', 'f', 'F', +# 'g', 'G' and '%' presentation types. _FORMAT_SPECIFICATION_MATCHER = re.compile(r""" (?: (?P.)? @@ -78,8 +146,8 @@ def _hash_algorithm(numerator, denominator): (?P[-+ ]?) (?Pz)? (?P\#)? - (?P0(?=\d))? - (?P\d+)? + (?P0(?=\d))? # use lookahead so that an isolated '0' is treated + (?P\d+)? # as minimum width rather than the zeropad flag (?P[,_])? (?:\.(?P\d+))? (?P[efg%]) @@ -327,35 +395,6 @@ def __str__(self): else: return '%s/%s' % (self._numerator, self._denominator) - def _round_to_sig_figs(self, figures): - """Round a positive fraction to a given number of significant figures. - - Returns a pair (significand, exponent) of integers such that - significand * 10**exponent gives a rounded approximation to self, and - significand lies in the range 10**(figures - 1) <= significand < - 10**figures. - """ - if not (self > 0 and figures > 0): - raise ValueError("Expected self and figures to be positive") - - # Find integer m satisfying 10**(m - 1) <= self <= 10**m. - str_n, str_d = str(self.numerator), str(self.denominator) - m = len(str_n) - len(str_d) + (str_d <= str_n) - - # Find best approximation significand * 10**exponent to self, with - # 10**(figures - 1) <= significand <= 10**figures. - exponent = m - figures - significand = round( - self / 10**exponent if exponent >= 0 else self * 10**-exponent - ) - - # Adjust in the case where significand == 10**figures. - if len(str(significand)) == figures + 1: - significand //= 10 - exponent += 1 - - return significand, exponent - def __format__(self, format_spec, /): """Format this fraction according to the given format specification.""" @@ -377,11 +416,10 @@ def __format__(self, format_spec, /): f"for object of type {type(self).__name__!r}; " "can't use explicit alignment when zero-padding" ) - fill = match["fill"] or " " align = match["align"] or ">" pos_sign = "" if match["sign"] == "-" else match["sign"] - neg_zero_ok = not match["no_neg_zero"] + no_neg_zero = bool(match["no_neg_zero"]) alternate_form = bool(match["alt"]) zeropad = bool(match["zeropad"]) minimumwidth = int(match["minimumwidth"] or "0") @@ -389,54 +427,50 @@ def __format__(self, format_spec, /): precision = int(match["precision"] or "6") presentation_type = match["presentation_type"] trim_zeros = presentation_type in "gG" and not alternate_form - trim_dot = not alternate_form + trim_point = not alternate_form exponent_indicator = "E" if presentation_type in "EFG" else "e" - # Record sign, then work with absolute value. - negative = self < 0 - self = abs(self) - - # Round to get the digits we need; also compute the suffix. - if presentation_type == "f" or presentation_type == "F": - significand = round(self * 10**precision) - point_pos = precision - suffix = "" - elif presentation_type == "%": - significand = round(self * 10**(precision + 2)) + # Round to get the digits we need, figure out where to place the point, + # and decide whether to use scientific notation. + n, d = self._numerator, self._denominator + if presentation_type in "fF%": + exponent = -precision - (2 if presentation_type == "%" else 0) + negative, significand = _round_to_exponent( + n, d, exponent, no_neg_zero) + scientific = False point_pos = precision + else: # presentation_type in "eEgG" + figures = ( + max(precision, 1) + if presentation_type in "gG" + else precision + 1 + ) + negative, significand, exponent = _round_to_figures(n, d, figures) + scientific = ( + presentation_type in "eE" + or exponent > 0 or exponent + figures <= -4 + ) + point_pos = figures - 1 if scientific else -exponent + + # Get the suffix - the part following the digits. + if presentation_type == "%": suffix = "%" - elif presentation_type in "eEgG": - if presentation_type in "gG": - figures = max(precision, 1) - else: - figures = precision + 1 - if self: - significand, exponent = self._round_to_sig_figs(figures) - else: - significand, exponent = 0, 1 - figures - if presentation_type in "gG" and -4 - figures < exponent <= 0: - point_pos = -exponent - suffix = "" - else: - point_pos = figures - 1 - suffix = f"{exponent_indicator}{exponent + point_pos:+03d}" + elif scientific: + suffix = f"{exponent_indicator}{exponent + point_pos:+03d}" else: - # It shouldn't be possible to get here. - raise ValueError( - f"unknown presentation type {presentation_type!r}" - ) + suffix = "" # Assemble the output: before padding, it has the form # f"{sign}{leading}{trailing}", where `leading` includes thousands # separators if necessary, and `trailing` includes the decimal # separator where appropriate. digits = f"{significand:0{point_pos + 1}d}" - sign = "-" if negative and (significand or neg_zero_ok) else pos_sign - leading = digits[:len(digits) - point_pos] - frac_part = digits[len(digits) - point_pos:] + sign = "-" if negative else pos_sign + leading = digits[: len(digits) - point_pos] + frac_part = digits[len(digits) - point_pos :] if trim_zeros: frac_part = frac_part.rstrip("0") - separator = "" if trim_dot and not frac_part else "." + separator = "" if trim_point and not frac_part else "." trailing = separator + frac_part + suffix # Do zero padding if required. @@ -452,11 +486,11 @@ def __format__(self, format_spec, /): if thousands_sep: first_pos = 1 + (len(leading) - 1) % 3 leading = leading[:first_pos] + "".join( - thousands_sep + leading[pos:pos+3] + thousands_sep + leading[pos : pos + 3] for pos in range(first_pos, len(leading), 3) ) - # Pad if necessary and return. + # Pad with fill character if necessary and return. body = leading + trailing padding = fill * (minimumwidth - len(sign) - len(body)) if align == ">": @@ -464,7 +498,7 @@ def __format__(self, format_spec, /): elif align == "<": return sign + body + padding elif align == "^": - half = len(padding)//2 + half = len(padding) // 2 return padding[:half] + sign + body + padding[half:] else: # align == "=" return sign + padding + body diff --git a/Lib/test/test_fractions.py b/Lib/test/test_fractions.py index efb1eb23dfac38..45f8f68f87a835 100644 --- a/Lib/test/test_fractions.py +++ b/Lib/test/test_fractions.py @@ -947,12 +947,23 @@ def test_format_f_presentation_type(self): (F(-2, 3), ' .2f', '-0.67'), # Formatting to zero places (F(1, 2), '.0f', '0'), + (F(-1, 2), '.0f', '-0'), (F(22, 7), '.0f', '3'), (F(-22, 7), '.0f', '-3'), # Formatting to zero places, alternate form (F(1, 2), '#.0f', '0.'), + (F(-1, 2), '#.0f', '-0.'), (F(22, 7), '#.0f', '3.'), (F(-22, 7), '#.0f', '-3.'), + # z flag for suppressing negative zeros + (F('-0.001'), 'z.2f', '0.00'), + (F('-0.001'), '-z.2f', '0.00'), + (F('-0.001'), '+z.2f', '+0.00'), + (F('-0.001'), ' z.2f', ' 0.00'), + (F('0.001'), 'z.2f', '0.00'), + (F('0.001'), '-z.2f', '0.00'), + (F('0.001'), '+z.2f', '+0.00'), + (F('0.001'), ' z.2f', ' 0.00'), # Corner-case: leading zeros are allowed in the precision (F(2, 3), '.02f', '0.67'), (F(22, 7), '.000f', '3'), @@ -1040,15 +1051,6 @@ def test_format_f_presentation_type(self): # is being inserted programmatically: spec = f'{width}.2f'. (F('12.34'), '0.2f', '12.34'), (F('12.34'), 'X>0.2f', '12.34'), - # z flag for suppressing negative zeros - (F('-0.001'), 'z.2f', '0.00'), - (F('-0.001'), '-z.2f', '0.00'), - (F('-0.001'), '+z.2f', '+0.00'), - (F('-0.001'), ' z.2f', ' 0.00'), - (F('0.001'), 'z.2f', '0.00'), - (F('0.001'), '-z.2f', '0.00'), - (F('0.001'), '+z.2f', '+0.00'), - (F('0.001'), ' z.2f', ' 0.00'), # "F" should work identically to "f" (F(22, 7), '.5F', '3.14286'), # %-specifier @@ -1088,6 +1090,15 @@ def test_format_g_presentation_type(self): (F('9.99999e+2'), '.4g', '1000'), (F('9.99999e-8'), '.4g', '1e-07'), (F('9.99999e+8'), '.4g', '1e+09'), + # Check round-ties-to-even behaviour + (F('-0.115'), '.2g', '-0.12'), + (F('-0.125'), '.2g', '-0.12'), + (F('-0.135'), '.2g', '-0.14'), + (F('-0.145'), '.2g', '-0.14'), + (F('0.115'), '.2g', '0.12'), + (F('0.125'), '.2g', '0.12'), + (F('0.135'), '.2g', '0.14'), + (F('0.145'), '.2g', '0.14'), # Trailing zeros and decimal point suppressed by default ... (F(0), '.6g', '0'), (F('123.400'), '.6g', '123.4'), From 48629b711fb1052e73f1ce584ba2b3f75e72043e Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Sat, 10 Dec 2022 15:30:31 +0000 Subject: [PATCH 08/31] Add news entry --- .../next/Library/2022-12-10-15-30-17.gh-issue-67790.P9YUZM.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2022-12-10-15-30-17.gh-issue-67790.P9YUZM.rst diff --git a/Misc/NEWS.d/next/Library/2022-12-10-15-30-17.gh-issue-67790.P9YUZM.rst b/Misc/NEWS.d/next/Library/2022-12-10-15-30-17.gh-issue-67790.P9YUZM.rst new file mode 100644 index 00000000000000..ba0db774f8b318 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2022-12-10-15-30-17.gh-issue-67790.P9YUZM.rst @@ -0,0 +1,2 @@ +Add float-style formatting support for :class:`fractions.Fraction` +instances. From 3d21af2b6bb4656c603a8ef1cc6f3f1de3d0a7a1 Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Sat, 10 Dec 2022 16:03:24 +0000 Subject: [PATCH 09/31] Add documentation --- Doc/library/fractions.rst | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Doc/library/fractions.rst b/Doc/library/fractions.rst index c46d88b2297aa1..d8f8b47ce8b677 100644 --- a/Doc/library/fractions.rst +++ b/Doc/library/fractions.rst @@ -101,6 +101,10 @@ another rational number, or from a string. .. versionchanged:: 3.12 Space is allowed around the slash for string inputs: ``Fraction('2 / 3')``. + .. versionchanged:: 3.12 + :class:`Fraction` instances now support float-style formatting, with + presentation types `"e"`, `"E"`, `"f"`, `"F"`, `"g"`, `"G"` and `"%""`. + .. attribute:: numerator Numerator of the Fraction in lowest term. @@ -187,6 +191,31 @@ another rational number, or from a string. ``ndigits`` is negative), again rounding half toward even. This method can also be accessed through the :func:`round` function. + .. method:: __format__(format_spec, /) + + This method provides support for float-style formatting of + :class:`Fraction` instances via the :meth:`str.format` method, the + :func:`format` built-in function, or :ref:`Formatted string literals + `. The presentation types `"e"`, `"E"`, `"f"`, `"F"`, `"g"`, + `"G"`` and `"%"` are supported. For these presentation types, formatting + for a :class:`Fraction` object `x` behaves as though the object `x` were + first converted to :class:`float` and then formatted using the float + formatting rules, but avoids the loss of precision that might arise as + a result of that conversion. + + Here are some examples:: + + >>> from fractions import Fraction + >>> format(Fraction(1, 7), '.40g') + '0.1428571428571428571428571428571428571429' + >>> format(Fraction('1234567.855'), '_.2f') + '1_234_567.86' + >>> f"{Fraction(355, 113):*>20.6e}" + '********3.141593e+00' + >>> old_price, new_price = 499, 672 + >>> "{:.2%} price increase".format(Fraction(new_price, old_price) - 1) + '34.67% price increase' + .. seealso:: From c86a57e8246fb41ecdc0aa246175049e031d6eee Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Sat, 10 Dec 2022 16:30:26 +0000 Subject: [PATCH 10/31] Add what's new entry --- Doc/whatsnew/3.12.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Doc/whatsnew/3.12.rst b/Doc/whatsnew/3.12.rst index 3f1ec0f9a3443b..80fb14fea55a73 100644 --- a/Doc/whatsnew/3.12.rst +++ b/Doc/whatsnew/3.12.rst @@ -249,6 +249,12 @@ dis :data:`~dis.hasarg` collection instead. (Contributed by Irit Katriel in :gh:`94216`.) +fractions +--------- + +* Objects of type :class:`fractions.Fraction` now support float-style + formatting. (Contributed by Mark Dickinson in :gh:`100161`.) + os -- From b521efbc863c129ef91a5bb6431e2a4981914b93 Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Sat, 10 Dec 2022 16:33:15 +0000 Subject: [PATCH 11/31] Fix backticks: --- Doc/library/fractions.rst | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Doc/library/fractions.rst b/Doc/library/fractions.rst index d8f8b47ce8b677..9f37e09d34301a 100644 --- a/Doc/library/fractions.rst +++ b/Doc/library/fractions.rst @@ -103,7 +103,8 @@ another rational number, or from a string. .. versionchanged:: 3.12 :class:`Fraction` instances now support float-style formatting, with - presentation types `"e"`, `"E"`, `"f"`, `"F"`, `"g"`, `"G"` and `"%""`. + presentation types ``"e"``, ``"E"``, ``"f"``, ``"F"``, ``"g"``, ``"G"`` + and ``"%""``. .. attribute:: numerator @@ -196,12 +197,12 @@ another rational number, or from a string. This method provides support for float-style formatting of :class:`Fraction` instances via the :meth:`str.format` method, the :func:`format` built-in function, or :ref:`Formatted string literals - `. The presentation types `"e"`, `"E"`, `"f"`, `"F"`, `"g"`, - `"G"`` and `"%"` are supported. For these presentation types, formatting - for a :class:`Fraction` object `x` behaves as though the object `x` were - first converted to :class:`float` and then formatted using the float - formatting rules, but avoids the loss of precision that might arise as - a result of that conversion. + `. The presentation types ``"e"``, ``"E"``, ``"f"``, ``"F"``, + ``"g"``, ``"G"`` and ``"%"`` are supported. For these presentation types, + formatting for a :class:`Fraction` object `x` behaves as though the + object `x` were first converted to :class:`float` and then formatted + using the float formatting rules, but avoids the loss of precision that + might arise as a result of that conversion. Here are some examples:: From aac576efd6492815bc37e29f704bcd0114632319 Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Sat, 10 Dec 2022 16:36:02 +0000 Subject: [PATCH 12/31] Fix more missing backticks --- Doc/library/fractions.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Doc/library/fractions.rst b/Doc/library/fractions.rst index 9f37e09d34301a..fa8ae82fcd88e4 100644 --- a/Doc/library/fractions.rst +++ b/Doc/library/fractions.rst @@ -199,8 +199,8 @@ another rational number, or from a string. :func:`format` built-in function, or :ref:`Formatted string literals `. The presentation types ``"e"``, ``"E"``, ``"f"``, ``"F"``, ``"g"``, ``"G"`` and ``"%"`` are supported. For these presentation types, - formatting for a :class:`Fraction` object `x` behaves as though the - object `x` were first converted to :class:`float` and then formatted + formatting for a :class:`Fraction` object ``x`` behaves as though the + object ``x`` were first converted to :class:`float` and then formatted using the float formatting rules, but avoids the loss of precision that might arise as a result of that conversion. From b9ee0ffcdb053647b2fd5b2304099468a783f47d Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Sat, 10 Dec 2022 16:40:28 +0000 Subject: [PATCH 13/31] Fix indentation --- Doc/library/fractions.rst | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Doc/library/fractions.rst b/Doc/library/fractions.rst index fa8ae82fcd88e4..6e931f8b50c4e1 100644 --- a/Doc/library/fractions.rst +++ b/Doc/library/fractions.rst @@ -206,16 +206,16 @@ another rational number, or from a string. Here are some examples:: - >>> from fractions import Fraction - >>> format(Fraction(1, 7), '.40g') - '0.1428571428571428571428571428571428571429' - >>> format(Fraction('1234567.855'), '_.2f') - '1_234_567.86' - >>> f"{Fraction(355, 113):*>20.6e}" - '********3.141593e+00' - >>> old_price, new_price = 499, 672 - >>> "{:.2%} price increase".format(Fraction(new_price, old_price) - 1) - '34.67% price increase' + >>> from fractions import Fraction + >>> format(Fraction(1, 7), '.40g') + '0.1428571428571428571428571428571428571429' + >>> format(Fraction('1234567.855'), '_.2f') + '1_234_567.86' + >>> f"{Fraction(355, 113):*>20.6e}" + '********3.141593e+00' + >>> old_price, new_price = 499, 672 + >>> "{:.2%} price increase".format(Fraction(new_price, old_price) - 1) + '34.67% price increase' .. seealso:: From 1c8b8a9970bbf82132a57542a5e1be07c3056b6d Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Sat, 10 Dec 2022 16:45:38 +0000 Subject: [PATCH 14/31] Wordsmithing for consistency with other method definitions --- Doc/library/fractions.rst | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Doc/library/fractions.rst b/Doc/library/fractions.rst index 6e931f8b50c4e1..9420e36bfe8305 100644 --- a/Doc/library/fractions.rst +++ b/Doc/library/fractions.rst @@ -194,15 +194,15 @@ another rational number, or from a string. .. method:: __format__(format_spec, /) - This method provides support for float-style formatting of - :class:`Fraction` instances via the :meth:`str.format` method, the - :func:`format` built-in function, or :ref:`Formatted string literals - `. The presentation types ``"e"``, ``"E"``, ``"f"``, ``"F"``, - ``"g"``, ``"G"`` and ``"%"`` are supported. For these presentation types, - formatting for a :class:`Fraction` object ``x`` behaves as though the - object ``x`` were first converted to :class:`float` and then formatted - using the float formatting rules, but avoids the loss of precision that - might arise as a result of that conversion. + Provides support for float-style formatting of :class:`Fraction` + instances via the :meth:`str.format` method, the :func:`format` built-in + function, or :ref:`Formatted string literals `. The + presentation types ``"e"``, ``"E"``, ``"f"``, ``"F"``, ``"g"``, ``"G"`` + and ``"%"`` are supported. For these presentation types, formatting for a + :class:`Fraction` object ``x`` behaves as though the object ``x`` were + first converted to :class:`float` and then formatted using the float + formatting rules, but avoids the loss of precision that might arise as a + result of that conversion. Here are some examples:: From 9dbde3b6088dcf223b03453411d7adb0f5f276a2 Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Sat, 10 Dec 2022 16:53:58 +0000 Subject: [PATCH 15/31] Add link to the format specification mini-language --- Doc/library/fractions.rst | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Doc/library/fractions.rst b/Doc/library/fractions.rst index 9420e36bfe8305..128f4ce40c579c 100644 --- a/Doc/library/fractions.rst +++ b/Doc/library/fractions.rst @@ -199,10 +199,8 @@ another rational number, or from a string. function, or :ref:`Formatted string literals `. The presentation types ``"e"``, ``"E"``, ``"f"``, ``"F"``, ``"g"``, ``"G"`` and ``"%"`` are supported. For these presentation types, formatting for a - :class:`Fraction` object ``x`` behaves as though the object ``x`` were - first converted to :class:`float` and then formatted using the float - formatting rules, but avoids the loss of precision that might arise as a - result of that conversion. + :class:`Fraction` object ``x`` follows the rules outlined for + the :class:`float` type in the :ref:`formatspec` section. Here are some examples:: From 2dd48bbb40a8030bc02d67678a6ed3e1c9844d92 Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Sat, 10 Dec 2022 17:12:52 +0000 Subject: [PATCH 16/31] Fix: not true that thousands separators cannot have an effect for the 'e' presentation type --- Lib/test/test_fractions.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_fractions.py b/Lib/test/test_fractions.py index 45f8f68f87a835..6f9f1082ccc5f9 100644 --- a/Lib/test/test_fractions.py +++ b/Lib/test/test_fractions.py @@ -888,9 +888,10 @@ def test_format_e_presentation_type(self): (F(22, 7), '11.6e', '3.142857e+00'), (F(22, 7), '12.6e', '3.142857e+00'), (F(22, 7), '13.6e', ' 3.142857e+00'), - # Legal to specify a thousands separator, but it'll have no effect + # Thousands separators (F('1234567.123456'), ',.5e', '1.23457e+06'), - # Same with z flag: legal, but useless + (F('123.123456'), '012_.2e', '0_001.23e+02'), + # z flag is legal, but never makes a different to the output (F(-1, 7**100), 'z.6e', '-3.091690e-85'), ] for fraction, spec, expected in testcases: From 983726f1986ff0bb34f6d063572640118e66491f Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Sat, 10 Dec 2022 17:13:15 +0000 Subject: [PATCH 17/31] Fix typo in comment --- Lib/test/test_fractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_fractions.py b/Lib/test/test_fractions.py index 6f9f1082ccc5f9..6eff2445917a41 100644 --- a/Lib/test/test_fractions.py +++ b/Lib/test/test_fractions.py @@ -891,7 +891,7 @@ def test_format_e_presentation_type(self): # Thousands separators (F('1234567.123456'), ',.5e', '1.23457e+06'), (F('123.123456'), '012_.2e', '0_001.23e+02'), - # z flag is legal, but never makes a different to the output + # z flag is legal, but never makes a difference to the output (F(-1, 7**100), 'z.6e', '-3.091690e-85'), ] for fraction, spec, expected in testcases: From b7e51298455496d2644fd345c25051ba69057cdd Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Wed, 21 Dec 2022 09:21:43 +0000 Subject: [PATCH 18/31] Tweak docstring and comments for _round_to_exponent --- Lib/fractions.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/Lib/fractions.py b/Lib/fractions.py index 9b216e59e9f683..cf82e25da589d2 100644 --- a/Lib/fractions.py +++ b/Lib/fractions.py @@ -72,27 +72,28 @@ def _hash_algorithm(numerator, denominator): # Helpers for formatting def _round_to_exponent(n, d, exponent, no_neg_zero=False): - """Round a rational number to an integer multiple of a power of 10. + """Round a rational number to the nearest multiple of a given power of 10. Rounds the rational number n/d to the nearest integer multiple of - 10**exponent using the round-ties-to-even rule, and returns a - pair (sign, significand) representing the rounded value - (-1)**sign * significand. + 10**exponent, rounding to the nearest even integer multiple in the case of + a tie. Returns a pair (sign: bool, significand: int) representing the + rounded value (-1)**sign * significand * 10**exponent. - d must be positive, but n and d need not be relatively prime. + If no_neg_zero is true, then the returned sign will always be False when + the significand is zero. Otherwise, the sign reflects the sign of the + input. - If no_neg_zero is true, then the returned sign will always be False - for a zero result. Otherwise, the sign is based on the sign of the input. + d must be positive, but n and d need not be relatively prime. """ if exponent >= 0: d *= 10**exponent else: n *= 10**-exponent - # The divmod quotient rounds ties towards positive infinity; we then adjust - # as needed for round-ties-to-even behaviour. + # The divmod quotient is correct for round-ties-towards-positive-infinity; + # In the case of a tie, we zero out the least significant bit of q. q, r = divmod(n + (d >> 1), d) - if r == 0 and d & 1 == 0: # Tie + if r == 0 and d & 1 == 0: q &= -2 sign = q < 0 if no_neg_zero else n < 0 From cb5e23484769c5f120eaaa28bebfb6a667153156 Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Wed, 21 Dec 2022 09:34:26 +0000 Subject: [PATCH 19/31] Second pass on docstring and comments for _round_to_figures --- Lib/fractions.py | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/Lib/fractions.py b/Lib/fractions.py index cf82e25da589d2..f4ef50ac04ee47 100644 --- a/Lib/fractions.py +++ b/Lib/fractions.py @@ -104,32 +104,34 @@ def _round_to_figures(n, d, figures): """Round a rational number to a given number of significant figures. Rounds the rational number n/d to the given number of significant figures - using the round-ties-to-even rule, and returns a triple (sign, significand, - exponent) representing the rounded value (-1)**sign * significand * - 10**exponent. + using the round-ties-to-even rule, and returns a triple + (sign: bool, significand: int, exponent: int) representing the rounded + value (-1)**sign * significand * 10**exponent. + + In the special case where n = 0, returns a significand of zero and + an exponent of 1 - figures, for compatibility with formatting. + Otherwise, the returned significand satisfies + 10**(figures - 1) <= significand < 10**figures. d must be positive, but n and d need not be relatively prime. figures must be positive. - - In the special case where n = 0, returns an exponent of 1 - figures, for - compatibility with formatting; the significand will be zero. Otherwise, - the significand satisfies 10**(figures - 1) <= significand < 10**figures. """ - # Find integer m satisfying 10**(m - 1) <= abs(self) <= 10**m if self - # is nonzero, with m = 1 if self = 0. (The latter choice is a little - # arbitrary, but gives the "right" results when formatting zero.) + # Special case for n == 0. if n == 0: - m = 1 - else: - str_n, str_d = str(abs(n)), str(d) - m = len(str_n) - len(str_d) + (str_d <= str_n) + return False, 0, 1 - figures + + # Find integer m satisfying 10**(m - 1) <= abs(n)/d <= 10**m. (If abs(n)/d + # is a power of 10, either of the two possible values for m is fine.) + str_n, str_d = str(abs(n)), str(d) + m = len(str_n) - len(str_d) + (str_d <= str_n) - # Round to a multiple of 10**(m - figures). The result will satisfy either - # significand == 0 or 10**(figures - 1) <= significand <= 10**figures. + # Round to a multiple of 10**(m - figures). The significand we get + # satisfies 10**(figures - 1) <= significand <= 10**figures. exponent = m - figures sign, significand = _round_to_exponent(n, d, exponent) - # Adjust in the case where significand == 10**figures. + # Adjust in the case where significand == 10**figures, to ensure that + # 10**(figures - 1) <= significand < 10**figures. if len(str(significand)) == figures + 1: significand //= 10 exponent += 1 From 907487e6963caa61fa6be5c1774571c17c3823d7 Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Wed, 21 Dec 2022 09:43:11 +0000 Subject: [PATCH 20/31] Add tests for the corner case of zero minimum width + alignment --- Lib/fractions.py | 4 ++-- Lib/test/test_fractions.py | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Lib/fractions.py b/Lib/fractions.py index f4ef50ac04ee47..ca6f19e6f2ac86 100644 --- a/Lib/fractions.py +++ b/Lib/fractions.py @@ -149,8 +149,8 @@ def _round_to_figures(n, d, figures): (?P[-+ ]?) (?Pz)? (?P\#)? - (?P0(?=\d))? # use lookahead so that an isolated '0' is treated - (?P\d+)? # as minimum width rather than the zeropad flag + (?P0(?=\d))? # lookahead so that an isolated '0' is treated + (?P\d+)? # as a minimum width rather than a zeropad flag (?P[,_])? (?:\.(?P\d+))? (?P[efg%]) diff --git a/Lib/test/test_fractions.py b/Lib/test/test_fractions.py index 6eff2445917a41..d18fea7ece273a 100644 --- a/Lib/test/test_fractions.py +++ b/Lib/test/test_fractions.py @@ -1000,7 +1000,13 @@ def test_format_f_presentation_type(self): (F(-2, 3), '-07.2f', '-000.67'), (F(2, 3), '+07.2f', '+000.67'), (F(2, 3), ' 07.2f', ' 000.67'), + # An isolated zero is a minimum width, not a zero-pad flag. + # So unlike zero-padding, it's legal in combination with alignment. (F(2, 3), '0.2f', '0.67'), + (F(2, 3), '>0.2f', '0.67'), + (F(2, 3), '<0.2f', '0.67'), + (F(2, 3), '^0.2f', '0.67'), + (F(2, 3), '=0.2f', '0.67'), # Thousands separator (only affects portion before the point) (F(2, 3), ',.2f', '0.67'), (F(2, 3), ',.7f', '0.6666667'), From aba35f37bed36c160ff3ecf1c04e1ad07d77ebcf Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Wed, 21 Dec 2022 09:46:07 +0000 Subject: [PATCH 21/31] Tests for the case of zero padding _and_ a zero minimum width --- Lib/test/test_fractions.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Lib/test/test_fractions.py b/Lib/test/test_fractions.py index d18fea7ece273a..bf034dafb6e20b 100644 --- a/Lib/test/test_fractions.py +++ b/Lib/test/test_fractions.py @@ -1007,6 +1007,8 @@ def test_format_f_presentation_type(self): (F(2, 3), '<0.2f', '0.67'), (F(2, 3), '^0.2f', '0.67'), (F(2, 3), '=0.2f', '0.67'), + # Corner case: zero-padding _and_ a zero minimum width. + (F(2, 3), '00.2f', '0.67'), # Thousands separator (only affects portion before the point) (F(2, 3), ',.2f', '0.67'), (F(2, 3), ',.7f', '0.6666667'), @@ -1169,6 +1171,8 @@ def test_invalid_formats(self): "=010f", "=010g", "=010%", + '>00.2f', + '>00f', # Missing precision ".e", ".f", From fc4d3b5ef7eff79c4dabf97f8c810b375071d3ee Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Wed, 21 Dec 2022 10:26:59 +0000 Subject: [PATCH 22/31] Cleanup of __format__ method --- Lib/fractions.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/Lib/fractions.py b/Lib/fractions.py index ca6f19e6f2ac86..5fc1ccaae43d55 100644 --- a/Lib/fractions.py +++ b/Lib/fractions.py @@ -435,11 +435,12 @@ def __format__(self, format_spec, /): # Round to get the digits we need, figure out where to place the point, # and decide whether to use scientific notation. - n, d = self._numerator, self._denominator if presentation_type in "fF%": - exponent = -precision - (2 if presentation_type == "%" else 0) + exponent = -precision + if presentation_type == "%": + exponent -= 2 negative, significand = _round_to_exponent( - n, d, exponent, no_neg_zero) + self._numerator, self._denominator, exponent, no_neg_zero) scientific = False point_pos = precision else: # presentation_type in "eEgG" @@ -448,10 +449,12 @@ def __format__(self, format_spec, /): if presentation_type in "gG" else precision + 1 ) - negative, significand, exponent = _round_to_figures(n, d, figures) + negative, significand, exponent = _round_to_figures( + self._numerator, self._denominator, figures) scientific = ( presentation_type in "eE" - or exponent > 0 or exponent + figures <= -4 + or exponent > 0 + or exponent + figures <= -4 ) point_pos = figures - 1 if scientific else -exponent @@ -493,8 +496,10 @@ def __format__(self, format_spec, /): for pos in range(first_pos, len(leading), 3) ) - # Pad with fill character if necessary and return. + # We now have a sign and a body. body = leading + trailing + + # Pad with fill character if necessary and return. padding = fill * (minimumwidth - len(sign) - len(body)) if align == ">": return padding + sign + body From 4ccdf942386277a85f9ae1f61b801048b5c4bede Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Wed, 21 Dec 2022 10:37:17 +0000 Subject: [PATCH 23/31] Add test cases from original issue and discussion thread --- Lib/test/test_fractions.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Lib/test/test_fractions.py b/Lib/test/test_fractions.py index bf034dafb6e20b..e3de529c4a9545 100644 --- a/Lib/test/test_fractions.py +++ b/Lib/test/test_fractions.py @@ -1075,6 +1075,18 @@ def test_format_f_presentation_type(self): (F(22, 7), '7.2%', '314.29%'), (F(22, 7), '8.2%', ' 314.29%'), (F(22, 7), '08.2%', '0314.29%'), + # Test cases from #67790 and discuss.python.org Ideas thread. + (F(1, 3), '.2f', '0.33'), + (F(1, 8), '.2f', '0.12'), + (F(3, 8), '.2f', '0.38'), + (F(2545, 1000), '.2f', '2.54'), + (F(2549, 1000), '.2f', '2.55'), + (F(2635, 1000), '.2f', '2.64'), + (F(1, 100), '.1f', '0.0'), + (F(49, 1000), '.1f', '0.0'), + (F(51, 1000), '.1f', '0.1'), + (F(149, 1000), '.1f', '0.1'), + (F(151, 1000), '.1f', '0.2'), ] for fraction, spec, expected in testcases: with self.subTest(fraction=fraction, spec=spec): From 67e020cc4ea2fbe625dc6af2151de005bc3a1d0d Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Wed, 21 Dec 2022 10:49:08 +0000 Subject: [PATCH 24/31] Tighten up the regex - extra leading zeros not permitted --- Lib/fractions.py | 8 +++++--- Lib/test/test_fractions.py | 9 ++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/Lib/fractions.py b/Lib/fractions.py index 5fc1ccaae43d55..a212d77312c2db 100644 --- a/Lib/fractions.py +++ b/Lib/fractions.py @@ -149,10 +149,12 @@ def _round_to_figures(n, d, figures): (?P[-+ ]?) (?Pz)? (?P\#)? - (?P0(?=\d))? # lookahead so that an isolated '0' is treated - (?P\d+)? # as a minimum width rather than a zeropad flag + # lookahead so that a single '0' is treated as a minimum width rather + # than a zeropad flag + (?P0(?=[0-9]))? + (?P0|[1-9][0-9]*)? (?P[,_])? - (?:\.(?P\d+))? + (?:\.(?P0|[1-9][0-9]*))? (?P[efg%]) """, re.DOTALL | re.IGNORECASE | re.VERBOSE).fullmatch diff --git a/Lib/test/test_fractions.py b/Lib/test/test_fractions.py index e3de529c4a9545..6aada43395a79b 100644 --- a/Lib/test/test_fractions.py +++ b/Lib/test/test_fractions.py @@ -965,9 +965,6 @@ def test_format_f_presentation_type(self): (F('0.001'), '-z.2f', '0.00'), (F('0.001'), '+z.2f', '+0.00'), (F('0.001'), ' z.2f', ' 0.00'), - # Corner-case: leading zeros are allowed in the precision - (F(2, 3), '.02f', '0.67'), - (F(22, 7), '.000f', '3'), # Specifying a minimum width (F(2, 3), '6.2f', ' 0.67'), (F(12345), '6.2f', '12345.00'), @@ -1185,6 +1182,12 @@ def test_invalid_formats(self): "=010%", '>00.2f', '>00f', + # Too many zeros - minimum width should not have leading zeros + '006f', + # Leading zeros in precision + '.010f', + '.02f', + '.000f', # Missing precision ".e", ".f", From 111c41f3b16375ad64ced4a41ed3259a8a64ebf7 Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Wed, 21 Dec 2022 10:57:05 +0000 Subject: [PATCH 25/31] Add tests for a few more fill character corner cases --- Lib/test/test_fractions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Lib/test/test_fractions.py b/Lib/test/test_fractions.py index 6aada43395a79b..ec0602d5b7929e 100644 --- a/Lib/test/test_fractions.py +++ b/Lib/test/test_fractions.py @@ -992,6 +992,9 @@ def test_format_f_presentation_type(self): (F(-2, 3), '\x00>7.2f', '\x00\x00-0.67'), (F(-2, 3), '\n>7.2f', '\n\n-0.67'), (F(-2, 3), '\t>7.2f', '\t\t-0.67'), + (F(-2, 3), '>>7.2f', '>>-0.67'), + (F(-2, 3), '<>7.2f', '<<-0.67'), + (F(-2, 3), '→>7.2f', '→→-0.67'), # Zero-padding (F(-2, 3), '07.2f', '-000.67'), (F(-2, 3), '-07.2f', '-000.67'), From fff3751aa8c11be668945ff07919f80b825373ff Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Sun, 22 Jan 2023 09:56:04 +0000 Subject: [PATCH 26/31] Add testcases for no presentation type with an integral fraction --- Lib/test/test_fractions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Lib/test/test_fractions.py b/Lib/test/test_fractions.py index ce81edf53c885e..e2c06358d8acdf 100644 --- a/Lib/test/test_fractions.py +++ b/Lib/test/test_fractions.py @@ -848,6 +848,8 @@ def test_format_no_presentation_type(self): testcases = [ (F(1, 3), '', '1/3'), (F(-1, 3), '', '-1/3'), + (F(3), '', '3'), + (F(-3), '', '-3'), ] for fraction, spec, expected in testcases: with self.subTest(fraction=fraction, spec=spec): From 0b8bfa6696c5257a41dfc567bf521475b3a009d8 Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Sun, 22 Jan 2023 10:09:17 +0000 Subject: [PATCH 27/31] Rename the regex to allow for future API expansion --- Lib/fractions.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Lib/fractions.py b/Lib/fractions.py index e05e57f0072e8a..581cb99cc9e201 100644 --- a/Lib/fractions.py +++ b/Lib/fractions.py @@ -139,9 +139,9 @@ def _round_to_figures(n, d, figures): return sign, significand, exponent -# Pattern for matching format specification; supports 'e', 'E', 'f', 'F', -# 'g', 'G' and '%' presentation types. -_FORMAT_SPECIFICATION_MATCHER = re.compile(r""" +# Pattern for matching float-style format specifications; +# supports 'e', 'E', 'f', 'F', 'g', 'G' and '%' presentation types. +_FLOAT_FORMAT_SPECIFICATION_MATCHER = re.compile(r""" (?: (?P.)? (?P[<>=^]) @@ -412,7 +412,7 @@ def __format__(self, format_spec, /): return str(self) # Validate and parse the format specifier. - match = _FORMAT_SPECIFICATION_MATCHER(format_spec) + match = _FLOAT_FORMAT_SPECIFICATION_MATCHER(format_spec) if match is None: raise ValueError( f"Invalid format specifier {format_spec!r} " From 54ed40211e5ac53b073a11ae1ca41c125dddb26d Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Sun, 22 Jan 2023 10:09:31 +0000 Subject: [PATCH 28/31] Tweak comment --- Lib/fractions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/fractions.py b/Lib/fractions.py index 581cb99cc9e201..0db0cbcb1627af 100644 --- a/Lib/fractions.py +++ b/Lib/fractions.py @@ -149,8 +149,8 @@ def _round_to_figures(n, d, figures): (?P[-+ ]?) (?Pz)? (?P\#)? - # lookahead so that a single '0' is treated as a minimum width rather - # than a zeropad flag + # A '0' that's *not* followed by another digit is parsed as a minimum width + # rather than a zeropad flag. (?P0(?=[0-9]))? (?P0|[1-9][0-9]*)? (?P[,_])? From e3a5fd2c328a63cd785bf71b92d4dc54f7d8250d Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Sun, 22 Jan 2023 10:28:24 +0000 Subject: [PATCH 29/31] Tweak algorithm comments --- Lib/fractions.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/Lib/fractions.py b/Lib/fractions.py index 0db0cbcb1627af..8f977ce372700e 100644 --- a/Lib/fractions.py +++ b/Lib/fractions.py @@ -440,7 +440,9 @@ def __format__(self, format_spec, /): exponent_indicator = "E" if presentation_type in "EFG" else "e" # Round to get the digits we need, figure out where to place the point, - # and decide whether to use scientific notation. + # and decide whether to use scientific notation. 'point_pos' is the + # relative to the _end_ of the digit string: that is, it's the number + # of digits that should follow the point. if presentation_type in "fF%": exponent = -precision if presentation_type == "%": @@ -464,7 +466,7 @@ def __format__(self, format_spec, /): ) point_pos = figures - 1 if scientific else -exponent - # Get the suffix - the part following the digits. + # Get the suffix - the part following the digits, if any. if presentation_type == "%": suffix = "%" elif scientific: @@ -472,11 +474,13 @@ def __format__(self, format_spec, /): else: suffix = "" - # Assemble the output: before padding, it has the form - # f"{sign}{leading}{trailing}", where `leading` includes thousands - # separators if necessary, and `trailing` includes the decimal - # separator where appropriate. + # String of output digits, padded sufficiently with zeros on the left + # so that we'll have at least one digit before the decimal point. digits = f"{significand:0{point_pos + 1}d}" + + # Before padding, the output has the form f"{sign}{leading}{trailing}", + # where `leading` includes thousands separators if necessary and + # `trailing` includes the decimal separator where appropriate. sign = "-" if negative else pos_sign leading = digits[: len(digits) - point_pos] frac_part = digits[len(digits) - point_pos :] @@ -502,10 +506,9 @@ def __format__(self, format_spec, /): for pos in range(first_pos, len(leading), 3) ) - # We now have a sign and a body. + # We now have a sign and a body. Pad with fill character if necessary + # and return. body = leading + trailing - - # Pad with fill character if necessary and return. padding = fill * (minimumwidth - len(sign) - len(body)) if align == ">": return padding + sign + body From 098d34c9118cbe2a880d3c204dd29d2954373440 Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Sun, 22 Jan 2023 13:18:27 +0000 Subject: [PATCH 30/31] Fix incorrect acceptance of Z instead of z --- Lib/fractions.py | 4 ++-- Lib/test/test_fractions.py | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Lib/fractions.py b/Lib/fractions.py index 8f977ce372700e..49a3f2841a2ed4 100644 --- a/Lib/fractions.py +++ b/Lib/fractions.py @@ -155,8 +155,8 @@ def _round_to_figures(n, d, figures): (?P0|[1-9][0-9]*)? (?P[,_])? (?:\.(?P0|[1-9][0-9]*))? - (?P[efg%]) -""", re.DOTALL | re.IGNORECASE | re.VERBOSE).fullmatch + (?P[eEfFgG%]) +""", re.DOTALL | re.VERBOSE).fullmatch class Fraction(numbers.Rational): diff --git a/Lib/test/test_fractions.py b/Lib/test/test_fractions.py index e2c06358d8acdf..90eed073a69799 100644 --- a/Lib/test/test_fractions.py +++ b/Lib/test/test_fractions.py @@ -1211,6 +1211,8 @@ def test_invalid_formats(self): ".f", ".g", ".%", + # Z instead of z for negative zero suppression + 'Z.2f' ] for spec in invalid_specs: with self.subTest(spec=spec): From 2c476a28c0b437d48b61acff558ec59f25d8158a Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Sun, 22 Jan 2023 13:21:38 +0000 Subject: [PATCH 31/31] Use consistent quote style in tests --- Lib/test/test_fractions.py | 42 +++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/Lib/test/test_fractions.py b/Lib/test/test_fractions.py index 90eed073a69799..3bc6b409e05dc3 100644 --- a/Lib/test/test_fractions.py +++ b/Lib/test/test_fractions.py @@ -1075,7 +1075,7 @@ def test_format_f_presentation_type(self): # is being inserted programmatically: spec = f'{width}.2f'. (F('12.34'), '0.2f', '12.34'), (F('12.34'), 'X>0.2f', '12.34'), - # "F" should work identically to "f" + # 'F' should work identically to 'f' (F(22, 7), '.5F', '3.14286'), # %-specifier (F(22, 7), '.2%', '314.29%'), @@ -1181,23 +1181,23 @@ def test_invalid_formats(self): format(fraction, None) invalid_specs = [ - "Q6f", # regression test + 'Q6f', # regression test # illegal to use fill or alignment when zero padding - "X>010f", - "X<010f", - "X^010f", - "X=010f", - "0>010f", - "0<010f", - "0^010f", - "0=010f", - ">010f", - "<010f", - "^010f", - "=010e", - "=010f", - "=010g", - "=010%", + 'X>010f', + 'X<010f', + 'X^010f', + 'X=010f', + '0>010f', + '0<010f', + '0^010f', + '0=010f', + '>010f', + '<010f', + '^010f', + '=010e', + '=010f', + '=010g', + '=010%', '>00.2f', '>00f', # Too many zeros - minimum width should not have leading zeros @@ -1207,10 +1207,10 @@ def test_invalid_formats(self): '.02f', '.000f', # Missing precision - ".e", - ".f", - ".g", - ".%", + '.e', + '.f', + '.g', + '.%', # Z instead of z for negative zero suppression 'Z.2f' ]