8000 gh-67790: Support basic formatting for Fraction by mdickinson · Pull Request #111320 · python/cpython · GitHub
[go: up one dir, main page]

Skip to content

gh-67790: Support basic formatting for Fraction #111320

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Dec 16, 2023
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
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
25 changes: 18 additions & 7 deletions Doc/library/fractions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ another rational number, or from a string.
presentation types ``"e"``, ``"E"``, ``"f"``, ``"F"``, ``"g"``, ``"G"``
and ``"%""``.

.. versionchanged:: 3.13
:class:`Fraction` instances now support integer-style formatting, with
presentation type ``"d"`` or missing presentation type.

.. attribute:: numerator

Numerator of the Fraction in lowest term.
Expand Down Expand Up @@ -201,13 +205,16 @@ another rational number, or from a string.

.. method:: __format__(format_spec, /)

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 <f-strings>`. The
presentation types ``"e"``, ``"E"``, ``"f"``, ``"F"``, ``"g"``, ``"G"``
and ``"%"`` are supported. For these presentation types, formatting for a
:class:`Fraction` object ``x`` follows the rules outlined for
the :class:`float` type in the :ref:`formatspec` section.
Provides support for formatting of :class:`Fraction` instances via the
:meth:`str.format` method, the :func:`format` built-in function, or
:ref:`Formatted string literals <f-strings>`. The presentation types
``"d"``, ``"e"``, ``"E"``, ``"f"``, ``"F"``, ``"g"``, ``"G"`` and ``"%"``
are supported. For presentation types other than ``"d"``, formatting for
:class:`Fraction` follows the rules outlined for the :class:`float` type
in the :ref:`formatspec` section. For presentation type ``"d"``,
formatting follows the rules for the :class:`int` type, except that the
zero-fill flag is not supported. If no presentation type is given, the
rules are identical to those for presentation type ``"d"``.

Here are some examples::

Expand All @@ -221,6 +228,10 @@ another rational number, or from a string.
>>> old_price, new_price = 499, 672
>>> "{:.2%} price increase".format(Fraction(new_price, old_price) - 1)
'34.67% price increase'
>>> format(Fraction(103993, 33102), '_d')
'103_993/33_102'
>>> format(Fraction(1, 7), '.^+10')
'...+1/7...'


.. seealso::
Expand Down
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:`111320`)

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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Implement basic formatting support (minimum width, alignment, fill) for
:class:`fractions.Fraction`.
0