From 8cf8d31ce701235491a0761236cd9f9dcf55146c Mon Sep 17 00:00:00 2001 From: Mark Dickinson Date: Sat, 3 Dec 2022 17:05:53 +0000 Subject: [PATCH 01/17] 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 75c7df14e1b9c77..1d64910724d87df 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 7fa9dbea905b592..f448e85a68ae74d 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/17] 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 1d64910724d87df..1534af8c34880d8 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 f448e85a68ae74d..d3598209421104f 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/17] 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 1534af8c34880d8..0713c89bdfdca66 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 d3598209421104f..77c3e9eaeb0ed8c 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/17] 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 0713c89bdfdca66..da28c9a5afe1522 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/17] 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 da28c9a5afe1522..a8c2d30084f4a3a 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 77c3e9eaeb0ed8c..21a978d661bca72 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/17] 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 a8c2d30084f4a3a..70a2a9d19b485f3 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 21a978d661bca72..efb1eb23dfac389 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/17] 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 70a2a9d19b485f3..9b216e59e9f6830 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 efb1eb23dfac389..45f8f68f87a8358 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/17] 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 000000000000000..ba0db774f8b318e --- /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/17] 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 c46d88b2297aa16..d8f8b47ce8b677d 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/17] 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 3f1ec0f9a3443b9..80fb14fea55a73c 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/17] 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 d8f8b47ce8b677d..9f37e09d34301a4 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/17] 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 9f37e09d34301a4..fa8ae82fcd88e40 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/17] 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 fa8ae82fcd88e40..6e931f8b50c4e10 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/17] 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 6e931f8b50c4e10..9420e36bfe83051 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/17] 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 9420e36bfe83051..128f4ce40c579c3 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/17] 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 45f8f68f87a8358..6f9f1082ccc5f91 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/17] 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 6f9f1082ccc5f91..6eff2445917a41e 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: