Skip to content

Commit

Permalink
pythongh-67790: Add integer-style formatting for Fraction type
Browse files Browse the repository at this point in the history
  • Loading branch information
mdickinson committed Oct 25, 2023
1 parent f6a45a0 commit 130c717
Show file tree
Hide file tree
Showing 3 changed files with 127 additions and 25 deletions.
7 changes: 7 additions & 0 deletions Doc/whatsnew/3.13.rst
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,13 @@ doctest
:attr:`doctest.TestResults.skipped` attributes.
(Contributed by Victor Stinner in :gh:`108794`.)

fractions
---------

* Objects of type :class:`fractions.Fraction` now support integer-style
formatting with the ``d`` presentation type. (Contributed by Mark Dickinson
in :gh:`?????`)

io
--

Expand Down
87 changes: 68 additions & 19 deletions Lib/fractions.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,26 @@ def _round_to_figures(n, d, figures):
return sign, significand, exponent


# Pattern for matching int-style format specifications;
# supports 'd' presentation type (and missing presentation type, interpreted
# as equivalent to 'd').
_INT_FORMAT_SPECIFICATION_MATCHER = re.compile(r"""
(?:
(?P<fill>.)?
(?P<align>[<>=^])
)?
(?P<sign>[-+ ]?)
# Alt flag forces a slash and denominator in the output, even for
# integer-valued Fraction objects.
(?P<alt>\#)?
# We don't implement the zeropad flag since there's no single obvious way
# to interpret it.
(?P<minimumwidth>0|[1-9][0-9]*)?
(?P<thousands_sep>[,_])?
(?P<presentation_type>d?)
""", re.DOTALL | re.VERBOSE).fullmatch


# Pattern for matching float-style format specifications;
# supports 'e', 'E', 'f', 'F', 'g', 'G' and '%' presentation types.
_FLOAT_FORMAT_SPECIFICATION_MATCHER = re.compile(r"""
Expand Down Expand Up @@ -414,27 +434,39 @@ 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)
def _format_int_style(self, match):
"""Helper method for __format__; handles 'd' presentation type."""

# Validate and parse the format specifier.
match = _FLOAT_FORMAT_SPECIFICATION_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"
)
fill = match["fill"] or " "
align = match["align"] or ">"
pos_sign = "" if match["sign"] == "-" else match["sign"]
alternate_form = bool(match["alt"])
minimumwidth = int(match["minimumwidth"] or "0")
thousands_sep = match["thousands_sep"] or ''

# Determine the body and sign representation.
n, d = self._numerator, self._denominator
if d > 1 or alternate_form:
body = f"{abs(n):{thousands_sep}}/{d:{thousands_sep}}"
else:
body = f"{abs(n):{thousands_sep}}"
sign = '-' if n < 0 else pos_sign

# Pad with fill character if necessary and return.
padding = fill * (minimumwidth - len(sign) - len(body))
if align == ">":
return padding + sign + body
elif align == "<":
return sign + body + padding
elif align == "^":
half = len(padding) // 2
return padding[:half] + sign + body + padding[half:]
else: # align == "="
return sign + padding + body

def _format_float_style(self, match):
"""Helper method for __format__; handles float presentation types."""
fill = match["fill"] or " "
align = match["align"] or ">"
pos_sign = "" if match["sign"] == "-" else match["sign"]
Expand Down Expand Up @@ -530,6 +562,23 @@ def __format__(self, format_spec, /):
else: # align == "="
return sign + padding + body

def __format__(self, format_spec, /):
"""Format this fraction according to the given format specification."""

if match := _INT_FORMAT_SPECIFICATION_MATCHER(format_spec):
return self._format_int_style(match)

if match := _FLOAT_FORMAT_SPECIFICATION_MATCHER(format_spec):
# Refuse the temptation to guess if both alignment _and_
# zero padding are specified.
if match["align"] is None or match["zeropad"] is None:
return self._format_float_style(match)

raise ValueError(
f"Invalid format specifier {format_spec!r} "
f"for object of type {type(self).__name__!r}"
)

def _operator_fallbacks(monomorphic_operator, fallback_operator):
"""Generates forward and reverse operators given a purely-rational
operator and a function from the operator module.
Expand Down
58 changes: 52 additions & 6 deletions Lib/test/test_fractions.py
Original file line number Diff line number Diff line change
Expand Up @@ -848,17 +848,57 @@ def denominator(self):
self.assertEqual(type(f.numerator), myint)
self.assertEqual(type(f.denominator), myint)

def test_format_no_presentation_type(self):
# Triples (fraction, specification, expected_result)
def test_format_d_presentation_type(self):
# Triples (fraction, specification, expected_result). We test both
# with and without a trailing 'd' on the specification.
testcases = [
(F(1, 3), '', '1/3'),
(F(-1, 3), '', '-1/3'),
(F(3), '', '3'),
(F(-3), '', '-3'),
# Explicit sign handling
(F(2, 3), '+', '+2/3'),
(F(-2, 3), '+', '-2/3'),
(F(3), '+', '+3'),
(F(-3), '+', '-3'),
(F(2, 3), ' ', ' 2/3'),
(F(-2, 3), ' ', '-2/3'),
(F(3), ' ', ' 3'),
(F(-3), ' ', '-3'),
(F(2, 3), '-', '2/3'),
(F(-2, 3), '-', '-2/3'),
(F(3), '-', '3'),
(F(-3), '-', '-3'),
# Padding
(F(0), '5', ' 0'),
(F(2, 3), '5', ' 2/3'),
(F(-2, 3), '5', ' -2/3'),
(F(2, 3), '0', '2/3'),
(F(2, 3), '1', '2/3'),
(F(2, 3), '2', '2/3'),
# Alignment
(F(2, 3), '<5', '2/3 '),
(F(2, 3), '>5', ' 2/3'),
(F(2, 3), '^5', ' 2/3 '),
(F(2, 3), '=5', ' 2/3'),
(F(-2, 3), '<5', '-2/3 '),
(F(-2, 3), '>5', ' -2/3'),
(F(-2, 3), '^5', '-2/3 '),
(F(-2, 3), '=5', '- 2/3'),
# Fill
(F(2, 3), 'X>5', 'XX2/3'),
(F(-2, 3), '.<5', '-2/3.'),
(F(-2, 3), '\n^6', '\n-2/3\n'),
# Thousands separators
(F(1234, 5679), ',', '1,234/5,679'),
(F(-1234, 5679), '_', '-1_234/5_679'),
(F(1234567), '_', '1_234_567'),
(F(-1234567), ',', '-1,234,567'),
# Alternate form forces a slash in the output
(F(123), '#', '123/1'),
(F(-123), '#', '-123/1'),
(F(0), '#', '0/1'),
]
for fraction, spec, expected in testcases:
with self.subTest(fraction=fraction, spec=spec):
self.assertEqual(format(fraction, spec), expected)
self.assertEqual(format(fraction, spec + 'd'), expected)

def test_format_e_presentation_type(self):
# Triples (fraction, specification, expected_result)
Expand Down Expand Up @@ -1218,6 +1258,12 @@ def test_invalid_formats(self):
'.%',
# Z instead of z for negative zero suppression
'Z.2f'
# D instead of d for integer-style formatting
'10D',
# z flag not supported for integer-style formatting
'zd',
# zero padding not supported for integer-style formatting
'05d',
]
for spec in invalid_specs:
with self.subTest(spec=spec):
Expand Down

0 comments on commit 130c717

Please sign in to comment.