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' ] pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy