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

gh-67790: Support float-style formatting for Fraction instances #100161

Merged
merged 34 commits into from
Jan 22, 2023
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
8cf8d31
Add '.f' formatting for Fraction objects
mdickinson Dec 3, 2022
e9db697
Add support for % and F format specifiers
mdickinson Dec 3, 2022
6491b04
Add support for 'z' flag
mdickinson Dec 4, 2022
4551468
Tidy up of business logic
mdickinson Dec 4, 2022
43d34fc
Add support for 'e' presentation type
mdickinson Dec 4, 2022
f8dfcb9
Add support for 'g' presentation type; tidy
mdickinson Dec 4, 2022
6bfbc6c
Tidying
mdickinson Dec 10, 2022
48629b7
Add news entry
mdickinson Dec 10, 2022
3d21af2
Add documentation
mdickinson Dec 10, 2022
c86a57e
Add what's new entry
mdickinson Dec 10, 2022
b521efb
Fix backticks:
mdickinson Dec 10, 2022
aac576e
Fix more missing backticks
mdickinson Dec 10, 2022
b9ee0ff
Fix indentation
mdickinson Dec 10, 2022
1c8b8a9
Wordsmithing for consistency with other method definitions
mdickinson Dec 10, 2022
9dbde3b
Add link to the format specification mini-language
mdickinson Dec 10, 2022
2dd48bb
Fix: not true that thousands separators cannot have an effect for the…
mdickinson Dec 10, 2022
983726f
Fix typo in comment
mdickinson Dec 10, 2022
b7e5129
Tweak docstring and comments for _round_to_exponent
mdickinson Dec 21, 2022
cb5e234
Second pass on docstring and comments for _round_to_figures
mdickinson Dec 21, 2022
907487e
Add tests for the corner case of zero minimum width + alignment
mdickinson Dec 21, 2022
aba35f3
Tests for the case of zero padding _and_ a zero minimum width
mdickinson Dec 21, 2022
fc4d3b5
Cleanup of __format__ method
mdickinson Dec 21, 2022
4ccdf94
Add test cases from original issue and discussion thread
mdickinson Dec 21, 2022
b358b37
Merge branch 'main' into fraction-format
mdickinson Dec 21, 2022
67e020c
Tighten up the regex - extra leading zeros not permitted
mdickinson Dec 21, 2022
e240c70
Merge remote-tracking branch 'mdickinson/fraction-format' into fracti…
mdickinson Dec 21, 2022
111c41f
Add tests for a few more fill character corner cases
mdickinson Dec 21, 2022
d8cc3d6
Merge remote-tracking branch 'upstream/main' into fraction-format
mdickinson Jan 22, 2023
fff3751
Add testcases for no presentation type with an integral fraction
mdickinson Jan 22, 2023
0b8bfa6
Rename the regex to allow for future API expansion
mdickinson Jan 22, 2023
54ed402
Tweak comment
mdickinson Jan 22, 2023
e3a5fd2
Tweak algorithm comments
mdickinson Jan 22, 2023
098d34c
Fix incorrect acceptance of Z instead of z
mdickinson Jan 22, 2023
2c476a2
Use consistent quote style in tests
mdickinson Jan 22, 2023
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 @@ -259,6 +259,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
203 changes: 203 additions & 0 deletions Lib/fractions.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,96 @@ 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 the nearest multiple of a given power of 10.

Rounds the rational number n/d to the nearest integer multiple of
10**exponent, rounding to the nearest even integer multiple in the case of
a tie. Returns a pair (sign: bool, significand: int) representing the
rounded value (-1)**sign * significand * 10**exponent.

If no_neg_zero is true, then the returned sign will always be False when
the significand is zero. Otherwise, the sign reflects the sign of the
input.

d must be positive, but n and d need not be relatively prime.
"""
if exponent >= 0:
d *= 10**exponent
else:
n *= 10**-exponent

# The divmod quotient is correct for round-ties-towards-positive-infinity;
# In the case of a tie, we zero out the least significant bit of q.
q, r = divmod(n + (d >> 1), d)
if r == 0 and d & 1 == 0:
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: bool, significand: int, exponent: int) representing the rounded
value (-1)**sign * significand * 10**exponent.

In the special case where n = 0, returns a significand of zero and
an exponent of 1 - figures, for compatibility with formatting.
Otherwise, the returned significand satisfies
10**(figures - 1) <= significand < 10**figures.

d must be positive, but n and d need not be relatively prime.
figures must be positive.
"""
# Special case for n == 0.
if n == 0:
return False, 0, 1 - figures

# Find integer m satisfying 10**(m - 1) <= abs(n)/d <= 10**m. (If abs(n)/d
# is a power of 10, either of the two possible values for m is fine.)
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 significand we get
# satisfies 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, to ensure that
# 10**(figures - 1) <= 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>\#)?
# lookahead so that a single '0' is treated as a minimum width rather
# than a zeropad flag
(?P<zeropad>0(?=[0-9]))?
(?P<minimumwidth>0|[1-9][0-9]*)?
(?P<thousands_sep>[,_])?
(?:\.(?P<precision>0|[1-9][0-9]*))?
(?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 +400,119 @@ 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.
if presentation_type in "fF%":
exponent = -precision
if presentation_type == "%":
exponent -= 2
negative, significand = _round_to_exponent(
self._numerator, self._denominator, 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(
self._numerator, self._denominator, 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)
)

# We now have a sign and a body.
body = leading + trailing

# 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 _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