Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
8cf8d31
Add '.f' formatting for Fraction objects
mdickinson Dec 3, 2022
e9db697
Add support for % and F format specifiers
mdickinson Dec 3, 2022
6491b04
Add support for 'z' flag
mdickinson Dec 4, 2022
4551468
Tidy up of business logic
mdickinson Dec 4, 2022
43d34fc
Add support for 'e' presentation type
mdickinson Dec 4, 2022
f8dfcb9
Add support for 'g' presentation type; tidy
mdickinson Dec 4, 2022
6bfbc6c
Tidying
mdickinson Dec 10, 2022
48629b7
Add news entry
mdickinson Dec 10, 2022
3d21af2
Add documentation
mdickinson Dec 10, 2022
c86a57e
Add what's new entry
mdickinson Dec 10, 2022
b521efb
Fix backticks:
mdickinson Dec 10, 2022
aac576e
Fix more missing backticks
mdickinson Dec 10, 2022
b9ee0ff
Fix indentation
mdickinson Dec 10, 2022
1c8b8a9
Wordsmithing for consistency with other method definitions
mdickinson Dec 10, 2022
9dbde3b
Add link to the format specification mini-language
mdickinson Dec 10, 2022
2dd48bb
Fix: not true that thousands separators cannot have an effect for the…
mdickinson Dec 10, 2022
983726f
Fix typo in comment
mdickinson Dec 10, 2022
b7e5129
Tweak docstring and comments for _round_to_exponent
mdickinson Dec 21, 2022
cb5e234
Second pass on docstring and comments for _round_to_figures
mdickinson Dec 21, 2022
907487e
Add tests for the corner case of zero minimum width + alignment
mdickinson Dec 21, 2022
aba35f3
Tests for the case of zero padding _and_ a zero minimum width
mdickinson Dec 21, 2022
fc4d3b5
Cleanup of __format__ method
mdickinson Dec 21, 2022
4ccdf94
Add test cases from original issue and discussion thread
mdickinson Dec 21, 2022
b358b37
Merge branch 'main' into fraction-format
mdickinson Dec 21, 2022
67e020c
Tighten up the regex - extra leading zeros not permitted
mdickinson Dec 21, 2022
e240c70
Merge remote-tracking branch 'mdickinson/fraction-format' into fracti…
mdickinson Dec 21, 2022
111c41f
Add tests for a few more fill character corner cases
mdickinson Dec 21, 2022
d8cc3d6
Merge remote-tracking branch 'upstream/main' into fraction-format
mdickinson Jan 22, 2023
fff3751
Add testcases for no presentation type with an integral fraction
mdickinson Jan 22, 2023
0b8bfa6
Rename the regex to allow for future API expansion
mdickinson Jan 22, 2023
54ed402
Tweak comment
mdickinson Jan 22, 2023
e3a5fd2
Tweak algorithm comments
mdickinson Jan 22, 2023
098d34c
Fix incorrect acceptance of Z instead of z
mdickinson Jan 22, 2023
2c476a2
Use consistent quote style in tests
mdickinson Jan 22, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Add support for 'g' presentation type; tidy
  • Loading branch information
mdickinson committed Dec 4, 2022
commit f8dfcb96ec2399154a3cf37518e67062e0d21c5a
125 changes: 79 additions & 46 deletions Lib/fractions.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def _hash_algorithm(numerator, denominator):
(?P<minimumwidth>\d+)?
(?P<thousands_sep>[,_])?
(?:\.(?P<precision>\d+))?
(?P<presentation_type>[ef%])
(?P<presentation_type>[efg%])
""", re.DOTALL | re.IGNORECASE | re.VERBOSE).fullmatch


Expand Down Expand Up @@ -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."""

Expand All @@ -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:
Expand All @@ -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
Expand Down
167 changes: 115 additions & 52 deletions Lib/test/test_fractions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand Down