gh-67790: Support basic formatting for Fraction (#111320)

PR #100161 added fancy float-style formatting for the Fraction type,
but left us in a state where basic formatting for fractions (alignment,
fill, minimum width, thousands separators) still wasn't supported.

This PR adds that support.

---------

Co-authored-by: Serhiy Storchaka <storchaka@gmail.com>
This commit is contained in:
Mark Dickinson 2023-12-16 10:58:31 +00:00 committed by GitHub
parent 84df3172ef
commit fe479fb8a9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 155 additions and 31 deletions

View File

@ -106,6 +106,10 @@ another rational number, or from a string.
presentation types ``"e"``, ``"E"``, ``"f"``, ``"F"``, ``"g"``, ``"G"`` presentation types ``"e"``, ``"E"``, ``"f"``, ``"F"``, ``"g"``, ``"G"``
and ``"%""``. 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 .. attribute:: numerator
Numerator of the Fraction in lowest term. Numerator of the Fraction in lowest term.
@ -201,17 +205,36 @@ another rational number, or from a string.
.. method:: __format__(format_spec, /) .. method:: __format__(format_spec, /)
Provides support for float-style formatting of :class:`Fraction` Provides support for formatting of :class:`Fraction` instances via the
instances via the :meth:`str.format` method, the :func:`format` built-in :meth:`str.format` method, the :func:`format` built-in function, or
function, or :ref:`Formatted string literals <f-strings>`. The :ref:`Formatted string literals <f-strings>`.
presentation types ``"e"``, ``"E"``, ``"f"``, ``"F"``, ``"g"``, ``"G"``
and ``"%"`` are supported. For these presentation types, formatting for a If the ``format_spec`` format specification string does not end with one
:class:`Fraction` object ``x`` follows the rules outlined for of the presentation types ``'e'``, ``'E'``, ``'f'``, ``'F'``, ``'g'``,
the :class:`float` type in the :ref:`formatspec` section. ``'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:: Here are some examples::
>>> from fractions import Fraction >>> 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') >>> format(Fraction(1, 7), '.40g')
'0.1428571428571428571428571428571428571429' '0.1428571428571428571428571428571428571429'
>>> format(Fraction('1234567.855'), '_.2f') >>> format(Fraction('1234567.855'), '_.2f')

View File

@ -212,6 +212,14 @@ email
(Contributed by Thomas Dwyer and Victor Stinner for :gh:`102988` to improve (Contributed by Thomas Dwyer and Victor Stinner for :gh:`102988` to improve
the CVE-2023-27043 fix.) 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 glob
---- ----

View File

@ -139,6 +139,23 @@ def _round_to_figures(n, d, figures):
return sign, significand, exponent 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; # Pattern for matching float-style format specifications;
# supports 'e', 'E', 'f', 'F', 'g', 'G' and '%' presentation types. # supports 'e', 'E', 'f', 'F', 'g', 'G' and '%' presentation types.
_FLOAT_FORMAT_SPECIFICATION_MATCHER = re.compile(r""" _FLOAT_FORMAT_SPECIFICATION_MATCHER = re.compile(r"""
@ -414,27 +431,42 @@ class Fraction(numbers.Rational):
else: else:
return '%s/%s' % (self._numerator, self._denominator) return '%s/%s' % (self._numerator, self._denominator)
def __format__(self, format_spec, /): def _format_general(self, match):
"""Format this fraction according to the given format specification.""" """Helper method for __format__.
# Backwards compatiblility with existing formatting.
if not format_spec:
return str(self)
Handles fill, alignment, signs, and thousands separators in the
case of no presentation type.
"""
# Validate and parse the format specifier. # Validate and parse the format specifier.
match = _FLOAT_FORMAT_SPECIFICATION_MATCHER(format_spec) fill = match["fill"] or " "
if match is None: align = match["align"] or ">"
raise ValueError( pos_sign = "" if match["sign"] == "-" else match["sign"]
f"Invalid format specifier {format_spec!r} " alternate_form = bool(match["alt"])
f"for object of type {type(self).__name__!r}" minimumwidth = int(match["minimumwidth"] or "0")
) thousands_sep = match["thousands_sep"] or ''
elif match["align"] is not None and match["zeropad"] is not None:
# Avoid the temptation to guess. # Determine the body and sign representation.
raise ValueError( n, d = self._numerator, self._denominator
f"Invalid format specifier {format_spec!r} " if d > 1 or alternate_form:
f"for object of type {type(self).__name__!r}; " body = f"{abs(n):{thousands_sep}}/{d:{thousands_sep}}"
"can't use explicit alignment when zero-padding" 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 " " fill = match["fill"] or " "
align = match["align"] or ">" align = match["align"] or ">"
pos_sign = "" if match["sign"] == "-" else match["sign"] pos_sign = "" if match["sign"] == "-" else match["sign"]
@ -530,6 +562,23 @@ class Fraction(numbers.Rational):
else: # align == "=" else: # align == "="
return sign + padding + body 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): def _operator_fallbacks(monomorphic_operator, fallback_operator):
"""Generates forward and reverse operators given a purely-rational """Generates forward and reverse operators given a purely-rational
operator and a function from the operator module. operator and a function from the operator module.

View File

@ -849,12 +849,50 @@ class FractionTest(unittest.TestCase):
self.assertEqual(type(f.denominator), myint) self.assertEqual(type(f.denominator), myint)
def test_format_no_presentation_type(self): def test_format_no_presentation_type(self):
# Triples (fraction, specification, expected_result) # Triples (fraction, specification, expected_result).
testcases = [ testcases = [
(F(1, 3), '', '1/3'), # Explicit sign handling
(F(-1, 3), '', '-1/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'),
(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: for fraction, spec, expected in testcases:
with self.subTest(fraction=fraction, spec=spec): with self.subTest(fraction=fraction, spec=spec):
@ -1218,6 +1256,10 @@ class FractionTest(unittest.TestCase):
'.%', '.%',
# Z instead of z for negative zero suppression # Z instead of z for negative zero suppression
'Z.2f' 'Z.2f'
# z flag not supported for general formatting
'z',
# zero padding not supported for general formatting
'05',
] ]
for spec in invalid_specs: for spec in invalid_specs:
with self.subTest(spec=spec): with self.subTest(spec=spec):

View File

@ -0,0 +1,2 @@
Implement basic formatting support (minimum width, alignment, fill) for
:class:`fractions.Fraction`.