Skip to content

Commit d385fe6

Browse files
mdickinsonserhiy-storchaka
authored andcommitted
pythongh-67790: Support basic formatting for Fraction (python#111320)
PR python#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 <[email protected]>
1 parent 6125ccf commit d385fe6

File tree

5 files changed

+155
-31
lines changed

5 files changed

+155
-31
lines changed

Doc/library/fractions.rst

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@ another rational number, or from a string.
106106
presentation types ``"e"``, ``"E"``, ``"f"``, ``"F"``, ``"g"``, ``"G"``
107107
and ``"%""``.
108108

109+
.. versionchanged:: 3.13
110+
Formatting of :class:`Fraction` instances without a presentation type
111+
now supports fill, alignment, sign handling, minimum width and grouping.
112+
109113
.. attribute:: numerator
110114

111115
Numerator of the Fraction in lowest term.
@@ -201,17 +205,36 @@ another rational number, or from a string.
201205

202206
.. method:: __format__(format_spec, /)
203207

204-
Provides support for float-style formatting of :class:`Fraction`
205-
instances via the :meth:`str.format` method, the :func:`format` built-in
206-
function, or :ref:`Formatted string literals <f-strings>`. The
207-
presentation types ``"e"``, ``"E"``, ``"f"``, ``"F"``, ``"g"``, ``"G"``
208-
and ``"%"`` are supported. For these presentation types, formatting for a
209-
:class:`Fraction` object ``x`` follows the rules outlined for
210-
the :class:`float` type in the :ref:`formatspec` section.
208+
Provides support for formatting of :class:`Fraction` instances via the
209+
:meth:`str.format` method, the :func:`format` built-in function, or
210+
:ref:`Formatted string literals <f-strings>`.
211+
212+
If the ``format_spec`` format specification string does not end with one
213+
of the presentation types ``'e'``, ``'E'``, ``'f'``, ``'F'``, ``'g'``,
214+
``'G'`` or ``'%'`` then formatting follows the general rules for fill,
215+
alignment, sign handling, minimum width, and grouping as described in the
216+
:ref:`format specification mini-language <formatspec>`. The "alternate
217+
form" flag ``'#'`` is supported: if present, it forces the output string
218+
to always include an explicit denominator, even when the value being
219+
formatted is an exact integer. The zero-fill flag ``'0'`` is not
220+
supported.
221+
222+
If the ``format_spec`` format specification string ends with one of
223+
the presentation types ``'e'``, ``'E'``, ``'f'``, ``'F'``, ``'g'``,
224+
``'G'`` or ``'%'`` then formatting follows the rules outlined for the
225+
:class:`float` type in the :ref:`formatspec` section.
211226

212227
Here are some examples::
213228

214229
>>> from fractions import Fraction
230+
>>> format(Fraction(103993, 33102), '_')
231+
'103_993/33_102'
232+
>>> format(Fraction(1, 7), '.^+10')
233+
'...+1/7...'
234+
>>> format(Fraction(3, 1), '')
235+
'3'
236+
>>> format(Fraction(3, 1), '#')
237+
'3/1'
215238
>>> format(Fraction(1, 7), '.40g')
216239
'0.1428571428571428571428571428571428571429'
217240
>>> format(Fraction('1234567.855'), '_.2f')

Doc/whatsnew/3.13.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,14 @@ email
212212
(Contributed by Thomas Dwyer and Victor Stinner for :gh:`102988` to improve
213213
the CVE-2023-27043 fix.)
214214

215+
fractions
216+
---------
217+
218+
* Formatting for objects of type :class:`fractions.Fraction` now supports
219+
the standard format specification mini-language rules for fill, alignment,
220+
sign handling, minimum width and grouping. (Contributed by Mark Dickinson
221+
in :gh:`111320`)
222+
215223
glob
216224
----
217225

Lib/fractions.py

Lines changed: 68 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,23 @@ def _round_to_figures(n, d, figures):
139139
return sign, significand, exponent
140140

141141

142+
# Pattern for matching non-float-style format specifications.
143+
_GENERAL_FORMAT_SPECIFICATION_MATCHER = re.compile(r"""
144+
(?:
145+
(?P<fill>.)?
146+
(?P<align>[<>=^])
147+
)?
148+
(?P<sign>[-+ ]?)
149+
# Alt flag forces a slash and denominator in the output, even for
150+
# integer-valued Fraction objects.
151+
(?P<alt>\#)?
152+
# We don't implement the zeropad flag since there's no single obvious way
153+
# to interpret it.
154+
(?P<minimumwidth>0|[1-9][0-9]*)?
155+
(?P<thousands_sep>[,_])?
156+
""", re.DOTALL | re.VERBOSE).fullmatch
157+
158+
142159
# Pattern for matching float-style format specifications;
143160
# supports 'e', 'E', 'f', 'F', 'g', 'G' and '%' presentation types.
144161
_FLOAT_FORMAT_SPECIFICATION_MATCHER = re.compile(r"""
@@ -414,27 +431,42 @@ def __str__(self):
414431
else:
415432
return '%s/%s' % (self._numerator, self._denominator)
416433

417-
def __format__(self, format_spec, /):
418-
"""Format this fraction according to the given format specification."""
419-
420-
# Backwards compatiblility with existing formatting.
421-
if not format_spec:
422-
return str(self)
434+
def _format_general(self, match):
435+
"""Helper method for __format__.
423436
437+
Handles fill, alignment, signs, and thousands separators in the
438+
case of no presentation type.
439+
"""
424440
# Validate and parse the format specifier.
425-
match = _FLOAT_FORMAT_SPECIFICATION_MATCHER(format_spec)
426-
if match is None:
427-
raise ValueError(
428-
f"Invalid format specifier {format_spec!r} "
429-
f"for object of type {type(self).__name__!r}"
430-
)
431-
elif match["align"] is not None and match["zeropad"] is not None:
432-
# Avoid the temptation to guess.
433-
raise ValueError(
434-
f"Invalid format specifier {format_spec!r} "
435-
f"for object of type {type(self).__name__!r}; "
436-
"can't use explicit alignment when zero-padding"
437-
)
441+
fill = match["fill"] or " "
442+
align = match["align"] or ">"
443+
pos_sign = "" if match["sign"] == "-" else match["sign"]
444+
alternate_form = bool(match["alt"])
445+
minimumwidth = int(match["minimumwidth"] or "0")
446+
thousands_sep = match["thousands_sep"] or ''
447+
448+
# Determine the body and sign representation.
449+
n, d = self._numerator, self._denominator
450+
if d > 1 or alternate_form:
451+
body = f"{abs(n):{thousands_sep}}/{d:{thousands_sep}}"
452+
else:
453+
body = f"{abs(n):{thousands_sep}}"
454+
sign = '-' if n < 0 else pos_sign
455+
456+
# Pad with fill character if necessary and return.
457+
padding = fill * (minimumwidth - len(sign) - len(body))
458+
if align == ">":
459+
return padding + sign + body
460+
elif align == "<":
461+
return sign + body + padding
462+
elif align == "^":
463+
half = len(padding) // 2
464+
return padding[:half] + sign + body + padding[half:]
465+
else: # align == "="
466+
return sign + padding + body
467+
468+
def _format_float_style(self, match):
469+
"""Helper method for __format__; handles float presentation types."""
438470
fill = match["fill"] or " "
439471
align = match["align"] or ">"
440472
pos_sign = "" if match["sign"] == "-" else match["sign"]
@@ -530,6 +562,23 @@ def __format__(self, format_spec, /):
530562
else: # align == "="
531563
return sign + padding + body
532564

565+
def __format__(self, format_spec, /):
566+
"""Format this fraction according to the given format specification."""
567+
568+
if match := _GENERAL_FORMAT_SPECIFICATION_MATCHER(format_spec):
569+
return self._format_general(match)
570+
571+
if match := _FLOAT_FORMAT_SPECIFICATION_MATCHER(format_spec):
572+
# Refuse the temptation to guess if both alignment _and_
573+
# zero padding are specified.
574+
if match["align"] is None or match["zeropad"] is None:
575+
return self._format_float_style(match)
576+
577+
raise ValueError(
578+
f"Invalid format specifier {format_spec!r} "
579+
f"for object of type {type(self).__name__!r}"
580+
)
581+
533582
def _operator_fallbacks(monomorphic_operator, fallback_operator):
534583
"""Generates forward and reverse operators given a purely-rational
535584
operator and a function from the operator module.

Lib/test/test_fractions.py

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -849,12 +849,50 @@ def denominator(self):
849849
self.assertEqual(type(f.denominator), myint)
850850

851851
def test_format_no_presentation_type(self):
852-
# Triples (fraction, specification, expected_result)
852+
# Triples (fraction, specification, expected_result).
853853
testcases = [
854-
(F(1, 3), '', '1/3'),
855-
(F(-1, 3), '', '-1/3'),
856-
(F(3), '', '3'),
857-
(F(-3), '', '-3'),
854+
# Explicit sign handling
855+
(F(2, 3), '+', '+2/3'),
856+
(F(-2, 3), '+', '-2/3'),
857+
(F(3), '+', '+3'),
858+
(F(-3), '+', '-3'),
859+
(F(2, 3), ' ', ' 2/3'),
860+
(F(-2, 3), ' ', '-2/3'),
861+
(F(3), ' ', ' 3'),
862+
(F(-3), ' ', '-3'),
863+
(F(2, 3), '-', '2/3'),
864+
(F(-2, 3), '-', '-2/3'),
865+
(F(3), '-', '3'),
866+
(F(-3), '-', '-3'),
867+
# Padding
868+
(F(0), '5', ' 0'),
869+
(F(2, 3), '5', ' 2/3'),
870+
(F(-2, 3), '5', ' -2/3'),
871+
(F(2, 3), '0', '2/3'),
872+
(F(2, 3), '1', '2/3'),
873+
(F(2, 3), '2', '2/3'),
874+
# Alignment
875+
(F(2, 3), '<5', '2/3 '),
876+
(F(2, 3), '>5', ' 2/3'),
877+
(F(2, 3), '^5', ' 2/3 '),
878+
(F(2, 3), '=5', ' 2/3'),
879+
(F(-2, 3), '<5', '-2/3 '),
880+
(F(-2, 3), '>5', ' -2/3'),
881+
(F(-2, 3), '^5', '-2/3 '),
882+
(F(-2, 3), '=5', '- 2/3'),
883+
# Fill
884+
(F(2, 3), 'X>5', 'XX2/3'),
885+
(F(-2, 3), '.<5', '-2/3.'),
886+
(F(-2, 3), '\n^6', '\n-2/3\n'),
887+
# Thousands separators
888+
(F(1234, 5679), ',', '1,234/5,679'),
889+
(F(-1234, 5679), '_', '-1_234/5_679'),
890+
(F(1234567), '_', '1_234_567'),
891+
(F(-1234567), ',', '-1,234,567'),
892+
# Alternate form forces a slash in the output
893+
(F(123), '#', '123/1'),
894+
(F(-123), '#', '-123/1'),
895+
(F(0), '#', '0/1'),
858896
]
859897
for fraction, spec, expected in testcases:
860898
with self.subTest(fraction=fraction, spec=spec):
@@ -1218,6 +1256,10 @@ def test_invalid_formats(self):
12181256
'.%',
12191257
# Z instead of z for negative zero suppression
12201258
'Z.2f'
1259+
# z flag not supported for general formatting
1260+
'z',
1261+
# zero padding not supported for general formatting
1262+
'05',
12211263
]
12221264
for spec in invalid_specs:
12231265
with self.subTest(spec=spec):
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Implement basic formatting support (minimum width, alignment, fill) for
2+
:class:`fractions.Fraction`.

0 commit comments

Comments
 (0)