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 all 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
37 changes: 30 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
Formatting of :class:`Fraction` instances without a presentation type
now supports fill, alignment, sign handling, minimum width and grouping.

.. attribute:: numerator

Numerator of the Fraction in lowest term.
Expand Down Expand Up @@ -201,17 +205,36 @@ 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>`.

If the ``format_spec`` format specification string does not end with one
of the presentation types ``'e'``, ``'E'``, ``'f'``, ``'F'``, ``'g'``,
``'G'`` or ``'%'`` then formatting follows the general rules for fill,
alignment, sign handling, minimum width, and grouping as described in the
:ref:`format specification mini-language <formatspec>`. The "alternate
form" flag ``'#'`` is supported: if present, it forces the output string
to always include an explicit denominator, even when the value being
formatted is an exact integer. The zero-fill flag ``'0'`` is not
supported.

If the ``format_spec`` format specification string ends with one of
the presentation types ``'e'``, ``'E'``, ``'f'``, ``'F'``, ``'g'``,
``'G'`` or ``'%'`` then formatting follows the rules outlined for the
:class:`float` type in the :ref:`formatspec` section.

Here are some examples::

>>> from fractions import Fraction
>>> format(Fraction(103993, 33102), '_')
'103_993/33_102'
>>> format(Fraction(1, 7), '.^+10')
'...+1/7...'
>>> format(Fraction(3, 1), '')
'3'
>>> format(Fraction(3, 1), '#')
'3/1'
>>> format(Fraction(1, 7), '.40g')
'0.1428571428571428571428571428571428571429'
>>> format(Fraction('1234567.855'), '_.2f')
Expand Down
8 changes: 8 additions & 0 deletions Doc/whatsnew/3.13.rst
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,14 @@ email
(Contributed by Thomas Dwyer and Victor Stinner for :gh:`102988` to improve
the CVE-2023-27043 fix.)

fractions
---------

* Formatting for objects of type :class:`fractions.Fraction` now supports
the standard format specification mini-language rules for fill, alignment,
sign handling, minimum width and grouping. (Contributed by Mark Dickinson
in :gh:`111320`)

glob
----

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,23 @@ def _round_to_figures(n, d, figures):
return sign, significand, exponent


# Pattern for matching non-float-style format specifications.
_GENERAL_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>[,_])?
""", 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 +431,42 @@ 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_general(self, match):
"""Helper method for __format__.

Handles fill, alignment, signs, and thousands separators in the
case of no 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 := _GENERAL_FORMAT_SPECIFICATION_MATCHER(format_spec):
return self._format_general(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
52 changes: 47 additions & 5 deletions Lib/test/test_fractions.py
Original file line number Diff line number Diff line change
Expand Up @@ -849,12 +849,50 @@ def denominator(self):
self.assertEqual(type(f.denominator), myint)

def test_format_no_presentation_type(self):
# Triples (fraction, specification, expected_result)
# Triples (fraction, specification, expected_result).
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):
Expand Down Expand Up @@ -1218,6 +1256,10 @@ def test_invalid_formats(self):
'.%',
# Z instead of z for negative zero suppression
'Z.2f'
# z flag not supported for general formatting
'z',
# zero padding not supported for general formatting
'05',
]
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`.