Skip to content

Commit 3e09f31

Browse files
authored
gh-67790: Support float-style formatting for Fraction instances (#100161)
This PR adds support for float-style formatting for `Fraction` objects: it supports the `"e"`, `"E"`, `"f"`, `"F"`, `"g"`, `"G"` and `"%"` presentation types, and all the various bells and whistles of the formatting mini-language for those presentation types. The behaviour almost exactly matches that of `float`, but the implementation works with the exact `Fraction` value and does not do an intermediate conversion to `float`, and so avoids loss of precision or issues with numbers that are outside the dynamic range of the `float` type. Note that the `"n"` presentation type is _not_ supported. That support could be added later if people have a need for it. There's one corner-case where the behaviour differs from that of float: for the `float` type, if explicit alignment is specified with a fill character of `'0'` and alignment type `'='`, then thousands separators (if specified) are inserted into the padding string: ```python >>> format(3.14, '0=11,.2f') '0,000,003.14' ``` The exact same effect can be achieved by using the `'0'` flag: ```python >>> format(3.14, '011,.2f') '0,000,003.14' ``` For `Fraction`, only the `'0'` flag has the above behaviour with respect to thousands separators: there's no special-casing of the particular `'0='` fill-character/alignment combination. Instead, we treat the fill character `'0'` just like any other: ```python >>> format(Fraction('3.14'), '0=11,.2f') '00000003.14' >>> format(Fraction('3.14'), '011,.2f') '0,000,003.14' ``` The `Fraction` formatter is also stricter about combining these two things: it's not permitted to use both the `'0'` flag _and_ explicit alignment, on the basis that we should refuse the temptation to guess in the face of ambiguity. `float` is less picky: ```python >>> format(3.14, '0<011,.2f') '3.140000000' >>> format(Fraction('3.14'), '0<011,.2f') Traceback (most recent call last): File "<stdin>", line 1, in <module> File "/Users/mdickinson/Repositories/python/cpython/Lib/fractions.py", line 414, in __format__ raise ValueError( ValueError: Invalid format specifier '0<011,.2f' for object of type 'Fraction'; can't use explicit alignment when zero-padding ```
1 parent b53bad6 commit 3e09f31

File tree

5 files changed

+618
-0
lines changed

5 files changed

+618
-0
lines changed

Doc/library/fractions.rst

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,11 @@ another rational number, or from a string.
101101
.. versionchanged:: 3.12
102102
Space is allowed around the slash for string inputs: ``Fraction('2 / 3')``.
103103

104+
.. versionchanged:: 3.12
105+
:class:`Fraction` instances now support float-style formatting, with
106+
presentation types ``"e"``, ``"E"``, ``"f"``, ``"F"``, ``"g"``, ``"G"``
107+
and ``"%""``.
108+
104109
.. attribute:: numerator
105110

106111
Numerator of the Fraction in lowest term.
@@ -193,6 +198,29 @@ another rational number, or from a string.
193198
``ndigits`` is negative), again rounding half toward even. This
194199
method can also be accessed through the :func:`round` function.
195200

201+
.. method:: __format__(format_spec, /)
202+
203+
Provides support for float-style formatting of :class:`Fraction`
204+
instances via the :meth:`str.format` method, the :func:`format` built-in
205+
function, or :ref:`Formatted string literals <f-strings>`. The
206+
presentation types ``"e"``, ``"E"``, ``"f"``, ``"F"``, ``"g"``, ``"G"``
207+
and ``"%"`` are supported. For these presentation types, formatting for a
208+
:class:`Fraction` object ``x`` follows the rules outlined for
209+
the :class:`float` type in the :ref:`formatspec` section.
210+
211+
Here are some examples::
212+
213+
>>> from fractions import Fraction
214+
>>> format(Fraction(1, 7), '.40g')
215+
'0.1428571428571428571428571428571428571429'
216+
>>> format(Fraction('1234567.855'), '_.2f')
217+
'1_234_567.86'
218+
>>> f"{Fraction(355, 113):*>20.6e}"
219+
'********3.141593e+00'
220+
>>> old_price, new_price = 499, 672
221+
>>> "{:.2%} price increase".format(Fraction(new_price, old_price) - 1)
222+
'34.67% price increase'
223+
196224

197225
.. seealso::
198226

Doc/whatsnew/3.12.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,12 @@ dis
269269
:data:`~dis.hasarg` collection instead.
270270
(Contributed by Irit Katriel in :gh:`94216`.)
271271

272+
fractions
273+
---------
274+
275+
* Objects of type :class:`fractions.Fraction` now support float-style
276+
formatting. (Contributed by Mark Dickinson in :gh:`100161`.)
277+
272278
math
273279
----
274280

Lib/fractions.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,96 @@ def _hash_algorithm(numerator, denominator):
6969
""", re.VERBOSE | re.IGNORECASE)
7070

7171

72+
# Helpers for formatting
73+
74+
def _round_to_exponent(n, d, exponent, no_neg_zero=False):
75+
"""Round a rational number to the nearest multiple of a given power of 10.
76+
77+
Rounds the rational number n/d to the nearest integer multiple of
78+
10**exponent, rounding to the nearest even integer multiple in the case of
79+
a tie. Returns a pair (sign: bool, significand: int) representing the
80+
rounded value (-1)**sign * significand * 10**exponent.
81+
82+
If no_neg_zero is true, then the returned sign will always be False when
83+
the significand is zero. Otherwise, the sign reflects the sign of the
84+
input.
85+
86+
d must be positive, but n and d need not be relatively prime.
87+
"""
88+
if exponent >= 0:
89+
d *= 10**exponent
90+
else:
91+
n *= 10**-exponent
92+
93+
# The divmod quotient is correct for round-ties-towards-positive-infinity;
94+
# In the case of a tie, we zero out the least significant bit of q.
95+
q, r = divmod(n + (d >> 1), d)
96+
if r == 0 and d & 1 == 0:
97+
q &= -2
98+
99+
sign = q < 0 if no_neg_zero else n < 0
100+
return sign, abs(q)
101+
102+
103+
def _round_to_figures(n, d, figures):
104+
"""Round a rational number to a given number of significant figures.
105+
106+
Rounds the rational number n/d to the given number of significant figures
107+
using the round-ties-to-even rule, and returns a triple
108+
(sign: bool, significand: int, exponent: int) representing the rounded
109+
value (-1)**sign * significand * 10**exponent.
110+
111+
In the special case where n = 0, returns a significand of zero and
112+
an exponent of 1 - figures, for compatibility with formatting.
113+
Otherwise, the returned significand satisfies
114+
10**(figures - 1) <= significand < 10**figures.
115+
116+
d must be positive, but n and d need not be relatively prime.
117+
figures must be positive.
118+
"""
119+
# Special case for n == 0.
120+
if n == 0:
121+
return False, 0, 1 - figures
122+
123+
# Find integer m satisfying 10**(m - 1) <= abs(n)/d <= 10**m. (If abs(n)/d
124+
# is a power of 10, either of the two possible values for m is fine.)
125+
str_n, str_d = str(abs(n)), str(d)
126+
m = len(str_n) - len(str_d) + (str_d <= str_n)
127+
128+
# Round to a multiple of 10**(m - figures). The significand we get
129+
# satisfies 10**(figures - 1) <= significand <= 10**figures.
130+
exponent = m - figures
131+
sign, significand = _round_to_exponent(n, d, exponent)
132+
133+
# Adjust in the case where significand == 10**figures, to ensure that
134+
# 10**(figures - 1) <= significand < 10**figures.
135+
if len(str(significand)) == figures + 1:
136+
significand //= 10
137+
exponent += 1
138+
139+
return sign, significand, exponent
140+
141+
142+
# Pattern for matching float-style format specifications;
143+
# supports 'e', 'E', 'f', 'F', 'g', 'G' and '%' presentation types.
144+
_FLOAT_FORMAT_SPECIFICATION_MATCHER = re.compile(r"""
145+
(?:
146+
(?P<fill>.)?
147+
(?P<align>[<>=^])
148+
)?
149+
(?P<sign>[-+ ]?)
150+
(?P<no_neg_zero>z)?
151+
(?P<alt>\#)?
152+
# A '0' that's *not* followed by another digit is parsed as a minimum width
153+
# rather than a zeropad flag.
154+
(?P<zeropad>0(?=[0-9]))?
155+
(?P<minimumwidth>0|[1-9][0-9]*)?
156+
(?P<thousands_sep>[,_])?
157+
(?:\.(?P<precision>0|[1-9][0-9]*))?
158+
(?P<presentation_type>[eEfFgG%])
159+
""", re.DOTALL | re.VERBOSE).fullmatch
160+
161+
72162
class Fraction(numbers.Rational):
73163
"""This class implements rational numbers.
74164
@@ -314,6 +404,122 @@ def __str__(self):
314404
else:
315405
return '%s/%s' % (self._numerator, self._denominator)
316406

407+
def __format__(self, format_spec, /):
408+
"""Format this fraction according to the given format specification."""
409+
410+
# Backwards compatiblility with existing formatting.
411+
if not format_spec:
412+
return str(self)
413+
414+
# Validate and parse the format specifier.
415+
match = _FLOAT_FORMAT_SPECIFICATION_MATCHER(format_spec)
416+
if match is None:
417+
raise ValueError(
418+
f"Invalid format specifier {format_spec!r} "
419+
f"for object of type {type(self).__name__!r}"
420+
)
421+
elif match["align"] is not None and match["zeropad"] is not None:
422+
# Avoid the temptation to guess.
423+
raise ValueError(
424+
f"Invalid format specifier {format_spec!r} "
425+
f"for object of type {type(self).__name__!r}; "
426+
"can't use explicit alignment when zero-padding"
427+
)
428+
fill = match["fill"] or " "
429+
align = match["align"] or ">"
430+
pos_sign = "" if match["sign"] == "-" else match["sign"]
431+
no_neg_zero = bool(match["no_neg_zero"])
432+
alternate_form = bool(match["alt"])
433+
zeropad = bool(match["zeropad"])
434+
minimumwidth = int(match["minimumwidth"] or "0")
435+
thousands_sep = match["thousands_sep"]
436+
precision = int(match["precision"] or "6")
437+
presentation_type = match["presentation_type"]
438+
trim_zeros = presentation_type in "gG" and not alternate_form
439+
trim_point = not alternate_form
440+
exponent_indicator = "E" if presentation_type in "EFG" else "e"
441+
442+
# Round to get the digits we need, figure out where to place the point,
443+
# and decide whether to use scientific notation. 'point_pos' is the
444+
# relative to the _end_ of the digit string: that is, it's the number
445+
# of digits that should follow the point.
446+
if presentation_type in "fF%":
447+
exponent = -precision
448+
if presentation_type == "%":
449+
exponent -= 2
450+
negative, significand = _round_to_exponent(
451+
self._numerator, self._denominator, exponent, no_neg_zero)
452+
scientific = False
453+
point_pos = precision
454+
else: # presentation_type in "eEgG"
455+
figures = (
456+
max(precision, 1)
457+
if presentation_type in "gG"
458+
else precision + 1
459+
)
460+
negative, significand, exponent = _round_to_figures(
461+
self._numerator, self._denominator, figures)
462+
scientific = (
463+
presentation_type in "eE"
464+
or exponent > 0
465+
or exponent + figures <= -4
466+
)
467+
point_pos = figures - 1 if scientific else -exponent
468+
469+
# Get the suffix - the part following the digits, if any.
470+
if presentation_type == "%":
471+
suffix = "%"
472+
elif scientific:
473+
suffix = f"{exponent_indicator}{exponent + point_pos:+03d}"
474+
else:
475+
suffix = ""
476+
477+
# String of output digits, padded sufficiently with zeros on the left
478+
# so that we'll have at least one digit before the decimal point.
479+
digits = f"{significand:0{point_pos + 1}d}"
480+
481+
# Before padding, the output has the form f"{sign}{leading}{trailing}",
482+
# where `leading` includes thousands separators if necessary and
483+
# `trailing` includes the decimal separator where appropriate.
484+
sign = "-" if negative else pos_sign
485+
leading = digits[: len(digits) - point_pos]
486+
frac_part = digits[len(digits) - point_pos :]
487+
if trim_zeros:
488+
frac_part = frac_part.rstrip("0")
489+
separator = "" if trim_point and not frac_part else "."
490+
trailing = separator + frac_part + suffix
491+
492+
# Do zero padding if required.
493+
if zeropad:
494+
min_leading = minimumwidth - len(sign) - len(trailing)
495+
# When adding thousands separators, they'll be added to the
496+
# zero-padded portion too, so we need to compensate.
497+
leading = leading.zfill(
498+
3 * min_leading // 4 + 1 if thousands_sep else min_leading
499+
)
500+
501+
# Insert thousands separators if required.
502+
if thousands_sep:
503+
first_pos = 1 + (len(leading) - 1) % 3
504+
leading = leading[:first_pos] + "".join(
505+
thousands_sep + leading[pos : pos + 3]
506+
for pos in range(first_pos, len(leading), 3)
507+
)
508+
509+
# We now have a sign and a body. Pad with fill character if necessary
510+
# and return.
511+
body = leading + trailing
512+
padding = fill * (minimumwidth - len(sign) - len(body))
513+
if align == ">":
514+
return padding + sign + body
515+
elif align == "<":
516+
return sign + body + padding
517+
elif align == "^":
518+
half = len(padding) // 2
519+
return padding[:half] + sign + body + padding[half:]
520+
else: # align == "="
521+
return sign + padding + body
522+
317523
def _operator_fallbacks(monomorphic_operator, fallback_operator):
318524
"""Generates forward and reverse operators given a purely-rational
319525
operator and a function from the operator module.

0 commit comments

Comments
 (0)