Skip to content

Commit 130c717

Browse files
committed
pythongh-67790: Add integer-style formatting for Fraction type
1 parent f6a45a0 commit 130c717

File tree

3 files changed

+127
-25
lines changed

3 files changed

+127
-25
lines changed

Doc/whatsnew/3.13.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,13 @@ doctest
171171
:attr:`doctest.TestResults.skipped` attributes.
172172
(Contributed by Victor Stinner in :gh:`108794`.)
173173

174+
fractions
175+
---------
176+
177+
* Objects of type :class:`fractions.Fraction` now support integer-style
178+
formatting with the ``d`` presentation type. (Contributed by Mark Dickinson
179+
in :gh:`?????`)
180+
174181
io
175182
--
176183

Lib/fractions.py

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

141141

142+
# Pattern for matching int-style format specifications;
143+
# supports 'd' presentation type (and missing presentation type, interpreted
144+
# as equivalent to 'd').
145+
_INT_FORMAT_SPECIFICATION_MATCHER = re.compile(r"""
146+
(?:
147+
(?P<fill>.)?
148+
(?P<align>[<>=^])
149+
)?
150+
(?P<sign>[-+ ]?)
151+
# Alt flag forces a slash and denominator in the output, even for
152+
# integer-valued Fraction objects.
153+
(?P<alt>\#)?
154+
# We don't implement the zeropad flag since there's no single obvious way
155+
# to interpret it.
156+
(?P<minimumwidth>0|[1-9][0-9]*)?
157+
(?P<thousands_sep>[,_])?
158+
(?P<presentation_type>d?)
159+
""", re.DOTALL | re.VERBOSE).fullmatch
160+
161+
142162
# Pattern for matching float-style format specifications;
143163
# supports 'e', 'E', 'f', 'F', 'g', 'G' and '%' presentation types.
144164
_FLOAT_FORMAT_SPECIFICATION_MATCHER = re.compile(r"""
@@ -414,27 +434,39 @@ def __str__(self):
414434
else:
415435
return '%s/%s' % (self._numerator, self._denominator)
416436

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)
437+
def _format_int_style(self, match):
438+
"""Helper method for __format__; handles 'd' presentation type."""
423439

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 := _INT_FORMAT_SPECIFICATION_MATCHER(format_spec):
569+
return self._format_int_style(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: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -848,17 +848,57 @@ def denominator(self):
848848
self.assertEqual(type(f.numerator), myint)
849849
self.assertEqual(type(f.denominator), myint)
850850

851-
def test_format_no_presentation_type(self):
852-
# Triples (fraction, specification, expected_result)
851+
def test_format_d_presentation_type(self):
852+
# Triples (fraction, specification, expected_result). We test both
853+
# with and without a trailing 'd' on the specification.
853854
testcases = [
854-
(F(1, 3), '', '1/3'),
855-
(F(-1, 3), '', '-1/3'),
856-
(F(3), '', '3'),
857-
(F(-3), '', '-3'),
855+
# Explicit sign handling
856+
(F(2, 3), '+', '+2/3'),
857+
(F(-2, 3), '+', '-2/3'),
858+
(F(3), '+', '+3'),
859+
(F(-3), '+', '-3'),
860+
(F(2, 3), ' ', ' 2/3'),
861+
(F(-2, 3), ' ', '-2/3'),
862+
(F(3), ' ', ' 3'),
863+
(F(-3), ' ', '-3'),
864+
(F(2, 3), '-', '2/3'),
865+
(F(-2, 3), '-', '-2/3'),
866+
(F(3), '-', '3'),
867+
(F(-3), '-', '-3'),
868+
# Padding
869+
(F(0), '5', ' 0'),
870+
(F(2, 3), '5', ' 2/3'),
871+
(F(-2, 3), '5', ' -2/3'),
872+
(F(2, 3), '0', '2/3'),
873+
(F(2, 3), '1', '2/3'),
874+
(F(2, 3), '2', '2/3'),
875+
# Alignment
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+
(F(-2, 3), '=5', '- 2/3'),
884+
# Fill
885+
(F(2, 3), 'X>5', 'XX2/3'),
886+
(F(-2, 3), '.<5', '-2/3.'),
887+
(F(-2, 3), '\n^6', '\n-2/3\n'),
888+
# Thousands separators
889+
(F(1234, 5679), ',', '1,234/5,679'),
890+
(F(-1234, 5679), '_', '-1_234/5_679'),
891+
(F(1234567), '_', '1_234_567'),
892+
(F(-1234567), ',', '-1,234,567'),
893+
# Alternate form forces a slash in the output
894+
(F(123), '#', '123/1'),
895+
(F(-123), '#', '-123/1'),
896+
(F(0), '#', '0/1'),
858897
]
859898
for fraction, spec, expected in testcases:
860899
with self.subTest(fraction=fraction, spec=spec):
861900
self.assertEqual(format(fraction, spec), expected)
901+
self.assertEqual(format(fraction, spec + 'd'), expected)
862902

863903
def test_format_e_presentation_type(self):
864904
# Triples (fraction, specification, expected_result)
@@ -1218,6 +1258,12 @@ def test_invalid_formats(self):
12181258
'.%',
12191259
# Z instead of z for negative zero suppression
12201260
'Z.2f'
1261+
# D instead of d for integer-style formatting
1262+
'10D',
1263+
# z flag not supported for integer-style formatting
1264+
'zd',
1265+
# zero padding not supported for integer-style formatting
1266+
'05d',
12211267
]
12221268
for spec in invalid_specs:
12231269
with self.subTest(spec=spec):

0 commit comments

Comments
 (0)