Skip to content
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

bpo-67790: Add float-style formatting for Fraction objects #1

Closed
wants to merge 17 commits into from
Closed
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
28 changes: 28 additions & 0 deletions Doc/library/fractions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ another rational number, or from a string.
.. versionchanged:: 3.12
Space is allowed around the slash for string inputs: ``Fraction('2 / 3')``.

.. versionchanged:: 3.12
:class:`Fraction` instances now support float-style formatting, with
presentation types ``"e"``, ``"E"``, ``"f"``, ``"F"``, ``"g"``, ``"G"``
and ``"%""``.

.. attribute:: numerator

Numerator of the Fraction in lowest term.
Expand Down Expand Up @@ -187,6 +192,29 @@ another rational number, or from a string.
``ndigits`` is negative), again rounding half toward even. This
method can also be accessed through the :func:`round` function.

.. 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.

Here are some examples::

>>> from fractions import Fraction
>>> format(Fraction(1, 7), '.40g')
'0.1428571428571428571428571428571428571429'
>>> format(Fraction('1234567.855'), '_.2f')
'1_234_567.86'
>>> f"{Fraction(355, 113):*>20.6e}"
'********3.141593e+00'
>>> old_price, new_price = 499, 672
>>> "{:.2%} price increase".format(Fraction(new_price, old_price) - 1)
'34.67% price increase'


.. seealso::

Expand Down
6 changes: 6 additions & 0 deletions Doc/whatsnew/3.12.rst
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,12 @@ dis
:data:`~dis.hasarg` collection instead.
(Contributed by Irit Katriel in :gh:`94216`.)

fractions
---------

* Objects of type :class:`fractions.Fraction` now support float-style
formatting. (Contributed by Mark Dickinson in :gh:`100161`.)

os
--

Expand Down
193 changes: 193 additions & 0 deletions Lib/fractions.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,91 @@ def _hash_algorithm(numerator, denominator):
""", re.VERBOSE | re.IGNORECASE)


# Helpers for formatting

def _round_to_exponent(n, d, exponent, no_neg_zero=False):
"""Round a rational number to an integer multiple of a power of 10.

Rounds the rational number n/d to the nearest integer multiple of
10**exponent using the round-ties-to-even rule, and returns a
pair (sign, significand) representing the rounded value
(-1)**sign * significand.

d must be positive, but n and d need not be relatively prime.

If no_neg_zero is true, then the returned sign will always be False
for a zero result. Otherwise, the sign is based on the sign of the input.
"""
if exponent >= 0:
d *= 10**exponent
else:
n *= 10**-exponent

# The divmod quotient rounds ties towards positive infinity; we then adjust
# as needed for round-ties-to-even behaviour.
q, r = divmod(n + (d >> 1), d)
if r == 0 and d & 1 == 0: # Tie
q &= -2

sign = q < 0 if no_neg_zero else n < 0
return sign, abs(q)


def _round_to_figures(n, d, figures):
"""Round a rational number to a given number of significant figures.

Rounds the rational number n/d to the given number of significant figures
using the round-ties-to-even rule, and returns a triple (sign, significand,
exponent) representing the rounded value (-1)**sign * significand *
10**exponent.

d must be positive, but n and d need not be relatively prime.
figures must be positive.

In the special case where n = 0, returns an exponent of 1 - figures, for
compatibility with formatting; the significand will be zero. Otherwise,
the significand satisfies 10**(figures - 1) <= significand < 10**figures.
"""
# Find integer m satisfying 10**(m - 1) <= abs(self) <= 10**m if self
# is nonzero, with m = 1 if self = 0. (The latter choice is a little
# arbitrary, but gives the "right" results when formatting zero.)
if n == 0:
m = 1
else:
str_n, str_d = str(abs(n)), str(d)
m = len(str_n) - len(str_d) + (str_d <= str_n)

# Round to a multiple of 10**(m - figures). The result will satisfy either
# significand == 0 or 10**(figures - 1) <= significand <= 10**figures.
exponent = m - figures
sign, significand = _round_to_exponent(n, d, exponent)

# Adjust in the case where significand == 10**figures.
if len(str(significand)) == figures + 1:
significand //= 10
exponent += 1

return sign, significand, exponent


# Pattern for matching format specification; supports 'e', 'E', 'f', 'F',
# 'g', 'G' and '%' presentation types.
_FORMAT_SPECIFICATION_MATCHER = re.compile(r"""
(?:
(?P<fill>.)?
(?P<align>[<>=^])
)?
(?P<sign>[-+ ]?)
(?P<no_neg_zero>z)?
(?P<alt>\#)?
(?P<zeropad>0(?=\d))? # use lookahead so that an isolated '0' is treated
(?P<minimumwidth>\d+)? # as minimum width rather than the zeropad flag
(?P<thousands_sep>[,_])?
(?:\.(?P<precision>\d+))?
(?P<presentation_type>[efg%])
""", re.DOTALL | re.IGNORECASE | re.VERBOSE).fullmatch


class Fraction(numbers.Rational):
"""This class implements rational numbers.

Expand Down Expand Up @@ -310,6 +395,114 @@ 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)

# Validate and parse the format specifier.
match = _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"]
no_neg_zero = bool(match["no_neg_zero"])
alternate_form = bool(match["alt"])
zeropad = bool(match["zeropad"])
minimumwidth = int(match["minimumwidth"] or "0")
thousands_sep = match["thousands_sep"]
precision = int(match["precision"] or "6")
presentation_type = match["presentation_type"]
trim_zeros = presentation_type in "gG" and not alternate_form
trim_point = not alternate_form
exponent_indicator = "E" if presentation_type in "EFG" else "e"

# Round to get the digits we need, figure out where to place the point,
# and decide whether to use scientific notation.
n, d = self._numerator, self._denominator
if presentation_type in "fF%":
exponent = -precision - (2 if presentation_type == "%" else 0)
negative, significand = _round_to_exponent(
n, d, exponent, no_neg_zero)
scientific = False
point_pos = precision
else: # presentation_type in "eEgG"
figures = (
max(precision, 1)
if presentation_type in "gG"
else precision + 1
)
negative, significand, exponent = _round_to_figures(n, d, figures)
scientific = (
presentation_type in "eE"
or exponent > 0 or exponent + figures <= -4
)
point_pos = figures - 1 if scientific else -exponent

# Get the suffix - the part following the digits.
if presentation_type == "%":
suffix = "%"
elif scientific:
suffix = f"{exponent_indicator}{exponent + point_pos:+03d}"
else:
suffix = ""

# Assemble the output: before padding, it has the form
# f"{sign}{leading}{trailing}", where `leading` includes thousands
# separators if necessary, and `trailing` includes the decimal
# separator where appropriate.
digits = f"{significand:0{point_pos + 1}d}"
sign = "-" if negative else pos_sign
leading = digits[: len(digits) - point_pos]
frac_part = digits[len(digits) - point_pos :]
if trim_zeros:
frac_part = frac_part.rstrip("0")
separator = "" if trim_point and not frac_part else "."
trailing = separator + frac_part + suffix

# Do zero padding if required.
if zeropad:
min_leading = minimumwidth - len(sign) - len(trailing)
# When adding thousands separators, they'll be added to the
# zero-padded portion too, so we need to compensate.
leading = leading.zfill(
3 * min_leading // 4 + 1 if thousands_sep else min_leading
)

# Insert thousands separators if required.
if thousands_sep:
first_pos = 1 + (len(leading) - 1) % 3
leading = leading[:first_pos] + "".join(
thousands_sep + leading[pos : pos + 3]
for pos in range(first_pos, len(leading), 3)
)

# Pad with fill character if necessary and return.
body = leading + trailing
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 _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
Loading