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
```
This commit is contained in:
Mark Dickinson 2023-01-22 18:44:49 +00:00 committed by GitHub
parent b53bad6dd0
commit 3e09f3152e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 618 additions and 0 deletions

View file

@ -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.
@ -193,6 +198,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::

View file

@ -269,6 +269,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`.)
math
----

View file

@ -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 float-style format specifications;
# supports 'e', 'E', 'f', 'F', 'g', 'G' and '%' presentation types.
_FLOAT_FORMAT_SPECIFICATION_MATCHER = re.compile(r"""
(?:
(?P<fill>.)?
(?P<align>[<>=^])
)?
(?P<sign>[-+ ]?)
(?P<no_neg_zero>z)?
(?P<alt>\#)?
# A '0' that's *not* followed by another digit is parsed 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>[eEfFgG%])
""", re.DOTALL | re.VERBOSE).fullmatch
class Fraction(numbers.Rational):
"""This class implements rational numbers.
@ -314,6 +404,122 @@ 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 = _FLOAT_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. 'point_pos' is the
# relative to the _end_ of the digit string: that is, it's the number
# of digits that should follow the point.
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 any.
if presentation_type == "%":
suffix = "%"
elif scientific:
suffix = f"{exponent_indicator}{exponent + point_pos:+03d}"
else:
suffix = ""
# String of output digits, padded sufficiently with zeros on the left
# so that we'll have at least one digit before the decimal point.
digits = f"{significand:0{point_pos + 1}d}"
# Before padding, the output has the form f"{sign}{leading}{trailing}",
# where `leading` includes thousands separators if necessary and
# `trailing` includes the decimal separator where appropriate.
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. 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.

View file

@ -843,6 +843,382 @@ def denominator(self):
self.assertEqual(type(f.numerator), myint)
self.assertEqual(type(f.denominator), myint)
def test_format_no_presentation_type(self):
# Triples (fraction, specification, expected_result)
testcases = [
(F(1, 3), '', '1/3'),
(F(-1, 3), '', '-1/3'),
(F(3), '', '3'),
(F(-3), '', '-3'),
]
for fraction, spec, expected in testcases:
with self.subTest(fraction=fraction, spec=spec):
self.assertEqual(format(fraction, spec), expected)
def test_format_e_presentation_type(self):
# Triples (fraction, specification, expected_result)
testcases = [
(F(2, 3), '.6e', '6.666667e-01'),
(F(3, 2), '.6e', '1.500000e+00'),
(F(2, 13), '.6e', '1.538462e-01'),
(F(2, 23), '.6e', '8.695652e-02'),
(F(2, 33), '.6e', '6.060606e-02'),
(F(13, 2), '.6e', '6.500000e+00'),
(F(20, 2), '.6e', '1.000000e+01'),
(F(23, 2), '.6e', '1.150000e+01'),
(F(33, 2), '.6e', '1.650000e+01'),
(F(2, 3), '.6e', '6.666667e-01'),
(F(3, 2), '.6e', '1.500000e+00'),
# Zero
(F(0), '.3e', '0.000e+00'),
# Powers of 10, to exercise the log10 boundary logic
(F(1, 1000), '.3e', '1.000e-03'),
(F(1, 100), '.3e', '1.000e-02'),
(F(1, 10), '.3e', '1.000e-01'),
(F(1, 1), '.3e', '1.000e+00'),
(F(10), '.3e', '1.000e+01'),
(F(100), '.3e', '1.000e+02'),
(F(1000), '.3e', '1.000e+03'),
# Boundary where we round up to the next power of 10
(F('99.999994999999'), '.6e', '9.999999e+01'),
(F('99.999995'), '.6e', '1.000000e+02'),
(F('99.999995000001'), '.6e', '1.000000e+02'),
# Negatives
(F(-2, 3), '.6e', '-6.666667e-01'),
(F(-3, 2), '.6e', '-1.500000e+00'),
(F(-100), '.6e', '-1.000000e+02'),
# Large and small
(F('1e1000'), '.3e', '1.000e+1000'),
(F('1e-1000'), '.3e', '1.000e-1000'),
# Using 'E' instead of 'e' should give us a capital 'E'
(F(2, 3), '.6E', '6.666667E-01'),
# Tiny precision
(F(2, 3), '.1e', '6.7e-01'),
(F('0.995'), '.0e', '1e+00'),
# Default precision is 6
(F(22, 7), 'e', '3.142857e+00'),
# Alternate form forces a decimal point
(F('0.995'), '#.0e', '1.e+00'),
# Check that padding takes the exponent into account.
(F(22, 7), '11.6e', '3.142857e+00'),
(F(22, 7), '12.6e', '3.142857e+00'),
(F(22, 7), '13.6e', ' 3.142857e+00'),
# Thousands separators
(F('1234567.123456'), ',.5e', '1.23457e+06'),
(F('123.123456'), '012_.2e', '0_001.23e+02'),
# z flag is legal, but never makes a difference to the output
(F(-1, 7**100), 'z.6e', '-3.091690e-85'),
]
for fraction, spec, expected in testcases:
with self.subTest(fraction=fraction, spec=spec):
self.assertEqual(format(fraction, spec), expected)
def test_format_f_presentation_type(self):
# Triples (fraction, specification, expected_result)
testcases = [
# Simple .f formatting
(F(0, 1), '.2f', '0.00'),
(F(1, 3), '.2f', '0.33'),
(F(2, 3), '.2f', '0.67'),
(F(4, 3), '.2f', '1.33'),
(F(1, 8), '.2f', '0.12'),
(F(3, 8), '.2f', '0.38'),
(F(1, 13), '.2f', '0.08'),
(F(1, 199), '.2f', '0.01'),
(F(1, 200), '.2f', '0.00'),
(F(22, 7), '.5f', '3.14286'),
(F('399024789'), '.2f', '399024789.00'),
# Large precision (more than float can provide)
(F(104348, 33215), '.50f',
'3.14159265392142104470871594159265392142104470871594'),
# Precision defaults to 6 if not given
(F(22, 7), 'f', '3.142857'),
(F(0), 'f', '0.000000'),
(F(-22, 7), 'f', '-3.142857'),
# Round-ties-to-even checks
(F('1.225'), '.2f', '1.22'),
(F('1.2250000001'), '.2f', '1.23'),
(F('1.2349999999'), '.2f', '1.23'),
(F('1.235'), '.2f', '1.24'),
(F('1.245'), '.2f', '1.24'),
(F('1.2450000001'), '.2f', '1.25'),
(F('1.2549999999'), '.2f', '1.25'),
(F('1.255'), '.2f', '1.26'),
(F('-1.225'), '.2f', '-1.22'),
(F('-1.2250000001'), '.2f', '-1.23'),
(F('-1.2349999999'), '.2f', '-1.23'),
(F('-1.235'), '.2f', '-1.24'),
(F('-1.245'), '.2f', '-1.24'),
(F('-1.2450000001'), '.2f', '-1.25'),
(F('-1.2549999999'), '.2f', '-1.25'),
(F('-1.255'), '.2f', '-1.26'),
# Negatives and sign handling
(F(2, 3), '.2f', '0.67'),
(F(2, 3), '-.2f', '0.67'),
(F(2, 3), '+.2f', '+0.67'),
(F(2, 3), ' .2f', ' 0.67'),
(F(-2, 3), '.2f', '-0.67'),
(F(-2, 3), '-.2f', '-0.67'),
(F(-2, 3), '+.2f', '-0.67'),
(F(-2, 3), ' .2f', '-0.67'),
# Formatting to zero places
(F(1, 2), '.0f', '0'),
(F(-1, 2), '.0f', '-0'),
(F(22, 7), '.0f', '3'),
(F(-22, 7), '.0f', '-3'),
# Formatting to zero places, alternate form
(F(1, 2), '#.0f', '0.'),
(F(-1, 2), '#.0f', '-0.'),
(F(22, 7), '#.0f', '3.'),
(F(-22, 7), '#.0f', '-3.'),
# z flag for suppressing negative zeros
(F('-0.001'), 'z.2f', '0.00'),
(F('-0.001'), '-z.2f', '0.00'),
(F('-0.001'), '+z.2f', '+0.00'),
(F('-0.001'), ' z.2f', ' 0.00'),
(F('0.001'), 'z.2f', '0.00'),
(F('0.001'), '-z.2f', '0.00'),
(F('0.001'), '+z.2f', '+0.00'),
(F('0.001'), ' z.2f', ' 0.00'),
# Specifying a minimum width
(F(2, 3), '6.2f', ' 0.67'),
(F(12345), '6.2f', '12345.00'),
(F(12345), '12f', '12345.000000'),
# Fill and alignment
(F(2, 3), '>6.2f', ' 0.67'),
(F(2, 3), '<6.2f', '0.67 '),
(F(2, 3), '^3.2f', '0.67'),
(F(2, 3), '^4.2f', '0.67'),
(F(2, 3), '^5.2f', '0.67 '),
(F(2, 3), '^6.2f', ' 0.67 '),
(F(2, 3), '^7.2f', ' 0.67 '),
(F(2, 3), '^8.2f', ' 0.67 '),
# '=' alignment
(F(-2, 3), '=+8.2f', '- 0.67'),
(F(2, 3), '=+8.2f', '+ 0.67'),
# Fill character
(F(-2, 3), 'X>3.2f', '-0.67'),
(F(-2, 3), 'X>7.2f', 'XX-0.67'),
(F(-2, 3), 'X<7.2f', '-0.67XX'),
(F(-2, 3), 'X^7.2f', 'X-0.67X'),
(F(-2, 3), 'X=7.2f', '-XX0.67'),
(F(-2, 3), ' >7.2f', ' -0.67'),
# Corner cases: weird fill characters
(F(-2, 3), '\x00>7.2f', '\x00\x00-0.67'),
(F(-2, 3), '\n>7.2f', '\n\n-0.67'),
(F(-2, 3), '\t>7.2f', '\t\t-0.67'),
(F(-2, 3), '>>7.2f', '>>-0.67'),
(F(-2, 3), '<>7.2f', '<<-0.67'),
(F(-2, 3), '→>7.2f', '→→-0.67'),
# Zero-padding
(F(-2, 3), '07.2f', '-000.67'),
(F(-2, 3), '-07.2f', '-000.67'),
(F(2, 3), '+07.2f', '+000.67'),
(F(2, 3), ' 07.2f', ' 000.67'),
# An isolated zero is a minimum width, not a zero-pad flag.
# So unlike zero-padding, it's legal in combination with alignment.
(F(2, 3), '0.2f', '0.67'),
(F(2, 3), '>0.2f', '0.67'),
(F(2, 3), '<0.2f', '0.67'),
(F(2, 3), '^0.2f', '0.67'),
(F(2, 3), '=0.2f', '0.67'),
# Corner case: zero-padding _and_ a zero minimum width.
(F(2, 3), '00.2f', '0.67'),
# Thousands separator (only affects portion before the point)
(F(2, 3), ',.2f', '0.67'),
(F(2, 3), ',.7f', '0.6666667'),
(F('123456.789'), ',.2f', '123,456.79'),
(F('1234567'), ',.2f', '1,234,567.00'),
(F('12345678'), ',.2f', '12,345,678.00'),
(F('12345678'), ',f', '12,345,678.000000'),
# Underscore as thousands separator
(F(2, 3), '_.2f', '0.67'),
(F(2, 3), '_.7f', '0.6666667'),
(F('123456.789'), '_.2f', '123_456.79'),
(F('1234567'), '_.2f', '1_234_567.00'),
(F('12345678'), '_.2f', '12_345_678.00'),
# Thousands and zero-padding
(F('1234.5678'), '07,.2f', '1,234.57'),
(F('1234.5678'), '08,.2f', '1,234.57'),
(F('1234.5678'), '09,.2f', '01,234.57'),
(F('1234.5678'), '010,.2f', '001,234.57'),
(F('1234.5678'), '011,.2f', '0,001,234.57'),
(F('1234.5678'), '012,.2f', '0,001,234.57'),
(F('1234.5678'), '013,.2f', '00,001,234.57'),
(F('1234.5678'), '014,.2f', '000,001,234.57'),
(F('1234.5678'), '015,.2f', '0,000,001,234.57'),
(F('1234.5678'), '016,.2f', '0,000,001,234.57'),
(F('-1234.5678'), '07,.2f', '-1,234.57'),
(F('-1234.5678'), '08,.2f', '-1,234.57'),
(F('-1234.5678'), '09,.2f', '-1,234.57'),
(F('-1234.5678'), '010,.2f', '-01,234.57'),
(F('-1234.5678'), '011,.2f', '-001,234.57'),
(F('-1234.5678'), '012,.2f', '-0,001,234.57'),
(F('-1234.5678'), '013,.2f', '-0,001,234.57'),
(F('-1234.5678'), '014,.2f', '-00,001,234.57'),
(F('-1234.5678'), '015,.2f', '-000,001,234.57'),
(F('-1234.5678'), '016,.2f', '-0,000,001,234.57'),
# Corner case: no decimal point
(F('-1234.5678'), '06,.0f', '-1,235'),
(F('-1234.5678'), '07,.0f', '-01,235'),
(F('-1234.5678'), '08,.0f', '-001,235'),
(F('-1234.5678'), '09,.0f', '-0,001,235'),
# Corner-case - zero-padding specified through fill and align
# instead of the zero-pad character - in this case, treat '0' as a
# regular fill character and don't attempt to insert commas into
# the filled portion. This differs from the int and float
# behaviour.
(F('1234.5678'), '0=12,.2f', '00001,234.57'),
# Corner case where it's not clear whether the '0' indicates zero
# padding or gives the minimum width, but there's still an obvious
# answer to give. We want this to work in case the minimum width
# is being inserted programmatically: spec = f'{width}.2f'.
(F('12.34'), '0.2f', '12.34'),
(F('12.34'), 'X>0.2f', '12.34'),
# 'F' should work identically to 'f'
(F(22, 7), '.5F', '3.14286'),
# %-specifier
(F(22, 7), '.2%', '314.29%'),
(F(1, 7), '.2%', '14.29%'),
(F(1, 70), '.2%', '1.43%'),
(F(1, 700), '.2%', '0.14%'),
(F(1, 7000), '.2%', '0.01%'),
(F(1, 70000), '.2%', '0.00%'),
(F(1, 7), '.0%', '14%'),
(F(1, 7), '#.0%', '14.%'),
(F(100, 7), ',.2%', '1,428.57%'),
(F(22, 7), '7.2%', '314.29%'),
(F(22, 7), '8.2%', ' 314.29%'),
(F(22, 7), '08.2%', '0314.29%'),
# Test cases from #67790 and discuss.python.org Ideas thread.
(F(1, 3), '.2f', '0.33'),
(F(1, 8), '.2f', '0.12'),
(F(3, 8), '.2f', '0.38'),
(F(2545, 1000), '.2f', '2.54'),
(F(2549, 1000), '.2f', '2.55'),
(F(2635, 1000), '.2f', '2.64'),
(F(1, 100), '.1f', '0.0'),
(F(49, 1000), '.1f', '0.0'),
(F(51, 1000), '.1f', '0.1'),
(F(149, 1000), '.1f', '0.1'),
(F(151, 1000), '.1f', '0.2'),
]
for fraction, spec, expected in testcases:
with self.subTest(fraction=fraction, spec=spec):
self.assertEqual(format(fraction, spec), expected)
def test_format_g_presentation_type(self):
# Triples (fraction, specification, expected_result)
testcases = [
(F('0.000012345678'), '.6g', '1.23457e-05'),
(F('0.00012345678'), '.6g', '0.000123457'),
(F('0.0012345678'), '.6g', '0.00123457'),
(F('0.012345678'), '.6g', '0.0123457'),
(F('0.12345678'), '.6g', '0.123457'),
(F('1.2345678'), '.6g', '1.23457'),
(F('12.345678'), '.6g', '12.3457'),
(F('123.45678'), '.6g', '123.457'),
(F('1234.5678'), '.6g', '1234.57'),
(F('12345.678'), '.6g', '12345.7'),
(F('123456.78'), '.6g', '123457'),
(F('1234567.8'), '.6g', '1.23457e+06'),
# Rounding up cases
(F('9.99999e+2'), '.4g', '1000'),
(F('9.99999e-8'), '.4g', '1e-07'),
(F('9.99999e+8'), '.4g', '1e+09'),
# Check round-ties-to-even behaviour
(F('-0.115'), '.2g', '-0.12'),
(F('-0.125'), '.2g', '-0.12'),
(F('-0.135'), '.2g', '-0.14'),
(F('-0.145'), '.2g', '-0.14'),
(F('0.115'), '.2g', '0.12'),
(F('0.125'), '.2g', '0.12'),
(F('0.135'), '.2g', '0.14'),
(F('0.145'), '.2g', '0.14'),
# Trailing zeros and decimal point suppressed by default ...
(F(0), '.6g', '0'),
(F('123.400'), '.6g', '123.4'),
(F('123.000'), '.6g', '123'),
(F('120.000'), '.6g', '120'),
(F('12000000'), '.6g', '1.2e+07'),
# ... but not when alternate form is in effect
(F(0), '#.6g', '0.00000'),
(F('123.400'), '#.6g', '123.400'),
(F('123.000'), '#.6g', '123.000'),
(F('120.000'), '#.6g', '120.000'),
(F('12000000'), '#.6g', '1.20000e+07'),
# 'G' format (uses 'E' instead of 'e' for the exponent indicator)
(F('123.45678'), '.6G', '123.457'),
(F('1234567.8'), '.6G', '1.23457E+06'),
# Default precision is 6 significant figures
(F('3.1415926535'), 'g', '3.14159'),
# Precision 0 is treated the same as precision 1.
(F('0.000031415'), '.0g', '3e-05'),
(F('0.00031415'), '.0g', '0.0003'),
(F('0.31415'), '.0g', '0.3'),
(F('3.1415'), '.0g', '3'),
(F('3.1415'), '#.0g', '3.'),
(F('31.415'), '.0g', '3e+01'),
(F('31.415'), '#.0g', '3.e+01'),
(F('0.000031415'), '.1g', '3e-05'),
(F('0.00031415'), '.1g', '0.0003'),
(F('0.31415'), '.1g', '0.3'),
(F('3.1415'), '.1g', '3'),
(F('3.1415'), '#.1g', '3.'),
(F('31.415'), '.1g', '3e+01'),
# Thousands separator
(F(2**64), '_.25g', '18_446_744_073_709_551_616'),
# As with 'e' format, z flag is legal, but has no effect
(F(-1, 7**100), 'zg', '-3.09169e-85'),
]
for fraction, spec, expected in testcases:
with self.subTest(fraction=fraction, spec=spec):
self.assertEqual(format(fraction, spec), expected)
def test_invalid_formats(self):
fraction = F(2, 3)
with self.assertRaises(TypeError):
format(fraction, None)
invalid_specs = [
'Q6f', # regression test
# illegal to use fill or alignment when zero padding
'X>010f',
'X<010f',
'X^010f',
'X=010f',
'0>010f',
'0<010f',
'0^010f',
'0=010f',
'>010f',
'<010f',
'^010f',
'=010e',
'=010f',
'=010g',
'=010%',
'>00.2f',
'>00f',
# Too many zeros - minimum width should not have leading zeros
'006f',
# Leading zeros in precision
'.010f',
'.02f',
'.000f',
# Missing precision
'.e',
'.f',
'.g',
'.%',
# Z instead of z for negative zero suppression
'Z.2f'
]
for spec in invalid_specs:
with self.subTest(spec=spec):
with self.assertRaises(ValueError):
format(fraction, spec)
if __name__ == '__main__':
unittest.main()

View file

@ -0,0 +1,2 @@
Add float-style formatting support for :class:`fractions.Fraction`
instances.