Skip to content

Commit 3d12e40

Browse files
committed
Implement "__format__" for Fraction, following python/cpython#100161
1 parent 8fddae4 commit 3d12e40

File tree

3 files changed

+614
-3
lines changed

3 files changed

+614
-3
lines changed

CHANGES.rst

+6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
ChangeLog
22
=========
33

4+
1.14 (2022-01-11)
5+
-----------------
6+
7+
* Implement ``__format__`` for ``Fraction``, following
8+
https://github.com/python/cpython/pull/100161
9+
410
1.13 (2022-01-11)
511
-----------------
612

src/quicktions.pyx

+221-2
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,14 @@ cdef extern from *:
3838
cdef long long PY_LLONG_MIN, PY_LLONG_MAX
3939
cdef long long MAX_SMALL_NUMBER "(PY_LLONG_MAX / 100)"
4040

41-
cdef object Rational, Integral, Real, Complex, Decimal, math, operator, sys
41+
cdef object Rational, Integral, Real, Complex, Decimal, math, operator, re, sys
4242
cdef object PY_MAX_LONG_LONG = PY_LLONG_MAX
4343

4444
from numbers import Rational, Integral, Real, Complex
4545
from decimal import Decimal
4646
import math
4747
import operator
48+
import re
4849
import sys
4950

5051
cdef bint _decimal_supports_integer_ratio = hasattr(Decimal, "as_integer_ratio") # Py3.6+
@@ -237,6 +238,99 @@ except AttributeError: # pre Py3.2
237238
_PyHASH_INF = hash(float('+inf'))
238239

239240

241+
# Helpers for formatting
242+
243+
cdef _round_to_exponent(n, d, exponent, bint no_neg_zero=False):
244+
"""Round a rational number to the nearest multiple of a given power of 10.
245+
246+
Rounds the rational number n/d to the nearest integer multiple of
247+
10**exponent, rounding to the nearest even integer multiple in the case of
248+
a tie. Returns a pair (sign: bool, significand: int) representing the
249+
rounded value (-1)**sign * significand * 10**exponent.
250+
251+
If no_neg_zero is true, then the returned sign will always be False when
252+
the significand is zero. Otherwise, the sign reflects the sign of the
253+
input.
254+
255+
d must be positive, but n and d need not be relatively prime.
256+
"""
257+
if exponent >= 0:
258+
d *= 10**exponent
259+
else:
260+
n *= 10**-exponent
261+
262+
# The divmod quotient is correct for round-ties-towards-positive-infinity;
263+
# In the case of a tie, we zero out the least significant bit of q.
264+
q, r = divmod(n + (d >> 1), d)
265+
if r == 0 and d & 1 == 0:
266+
q &= -2
267+
268+
cdef bint sign = q < 0 if no_neg_zero else n < 0
269+
return sign, abs(q)
270+
271+
272+
cdef _round_to_figures(n, d, Py_ssize_t figures):
273+
"""Round a rational number to a given number of significant figures.
274+
275+
Rounds the rational number n/d to the given number of significant figures
276+
using the round-ties-to-even rule, and returns a triple
277+
(sign: bool, significand: int, exponent: int) representing the rounded
278+
value (-1)**sign * significand * 10**exponent.
279+
280+
In the special case where n = 0, returns a significand of zero and
281+
an exponent of 1 - figures, for compatibility with formatting.
282+
Otherwise, the returned significand satisfies
283+
10**(figures - 1) <= significand < 10**figures.
284+
285+
d must be positive, but n and d need not be relatively prime.
286+
figures must be positive.
287+
"""
288+
# Special case for n == 0.
289+
if n == 0:
290+
return False, 0, 1 - figures
291+
292+
cdef bint sign
293+
294+
# Find integer m satisfying 10**(m - 1) <= abs(n)/d <= 10**m. (If abs(n)/d
295+
# is a power of 10, either of the two possible values for m is fine.)
296+
str_n, str_d = str(abs(n)), str(d)
297+
cdef Py_ssize_t m = len(str_n) - len(str_d) + (str_d <= str_n)
298+
299+
# Round to a multiple of 10**(m - figures). The significand we get
300+
# satisfies 10**(figures - 1) <= significand <= 10**figures.
301+
exponent = m - figures
302+
sign, significand = _round_to_exponent(n, d, exponent)
303+
304+
# Adjust in the case where significand == 10**figures, to ensure that
305+
# 10**(figures - 1) <= significand < 10**figures.
306+
if len(str(significand)) == figures + 1:
307+
significand //= 10
308+
exponent += 1
309+
310+
return sign, significand, exponent
311+
312+
313+
# Pattern for matching float-style format specifications;
314+
# supports 'e', 'E', 'f', 'F', 'g', 'G' and '%' presentation types.
315+
cdef object _FLOAT_FORMAT_SPECIFICATION_MATCHER = re.compile(r"""
316+
(?:
317+
(?P<fill>.)?
318+
(?P<align>[<>=^])
319+
)?
320+
(?P<sign>[-+ ]?)
321+
(?P<no_neg_zero>z)?
322+
(?P<alt>\#)?
323+
# A '0' that's *not* followed by another digit is parsed as a minimum width
324+
# rather than a zeropad flag.
325+
(?P<zeropad>0(?=[0-9]))?
326+
(?P<minimumwidth>0|[1-9][0-9]*)?
327+
(?P<thousands_sep>[,_])?
328+
(?:\.(?P<precision>0|[1-9][0-9]*))?
329+
(?P<presentation_type>[eEfFgG%])
330+
$
331+
""", re.DOTALL | re.VERBOSE).match
332+
333+
240334
cdef class Fraction:
241335
"""A Rational number.
242336
@@ -495,9 +589,132 @@ cdef class Fraction:
495589
"""str(self)"""
496590
if self._denominator == 1:
497591
return str(self._numerator)
592+
elif PY_MAJOR_VERSION > 2:
593+
return f'{self._numerator}/{self._denominator}'
498594
else:
499595
return '%s/%s' % (self._numerator, self._denominator)
500596

597+
def __format__(self, format_spec, /):
598+
"""Format this fraction according to the given format specification."""
599+
600+
# Backwards compatibility with existing formatting.
601+
if not format_spec:
602+
return str(self)
603+
604+
# Validate and parse the format specifier.
605+
match = _FLOAT_FORMAT_SPECIFICATION_MATCHER(format_spec)
606+
if match is None:
607+
raise ValueError(
608+
f"Invalid format specifier {format_spec!r} "
609+
f"for object of type {type(self).__name__!r}"
610+
)
611+
match = match.groupdict() # Py2
612+
if match["align"] is not None and match["zeropad"] is not None:
613+
# Avoid the temptation to guess.
614+
raise ValueError(
615+
f"Invalid format specifier {format_spec!r} "
616+
f"for object of type {type(self).__name__!r}; "
617+
"can't use explicit alignment when zero-padding"
618+
)
619+
fill = match["fill"] or " "
620+
align = match["align"] or ">"
621+
pos_sign = "" if match["sign"] == "-" else match["sign"]
622+
cdef bint no_neg_zero = match["no_neg_zero"]
623+
cdef bint alternate_form = match["alt"]
624+
cdef bint zeropad = match["zeropad"]
625+
cdef Py_ssize_t minimumwidth = int(match["minimumwidth"] or "0")
626+
thousands_sep = match["thousands_sep"]
627+
cdef Py_ssize_t precision = int(match["precision"] or "6")
628+
cdef Py_UCS4 presentation_type = ord(match["presentation_type"])
629+
cdef bint trim_zeros = presentation_type in u"gG" and not alternate_form
630+
cdef bint trim_point = not alternate_form
631+
exponent_indicator = "E" if presentation_type in u"EFG" else "e"
632+
633+
cdef bint negative, scientific
634+
cdef Py_ssize_t exponent, figures
635+
636+
# Round to get the digits we need, figure out where to place the point,
637+
# and decide whether to use scientific notation. 'point_pos' is the
638+
# relative to the _end_ of the digit string: that is, it's the number
639+
# of digits that should follow the point.
640+
if presentation_type in u"fF%":
641+
exponent = -precision
642+
if presentation_type == u"%":
643+
exponent -= 2
644+
negative, significand = _round_to_exponent(
645+
self._numerator, self._denominator, exponent, no_neg_zero)
646+
scientific = False
647+
point_pos = precision
648+
else: # presentation_type in "eEgG"
649+
figures = (
650+
max(precision, 1)
651+
if presentation_type in u"gG"
652+
else precision + 1
653+
)
654+
negative, significand, exponent = _round_to_figures(
655+
self._numerator, self._denominator, figures)
656+
scientific = (
657+
presentation_type in u"eE"
658+
or exponent > 0
659+
or exponent + figures <= -4
660+
)
661+
point_pos = figures - 1 if scientific else -exponent
662+
663+
# Get the suffix - the part following the digits, if any.
664+
if presentation_type == u"%":
665+
suffix = "%"
666+
elif scientific:
667+
#suffix = f"{exponent_indicator}{exponent + point_pos:+03d}"
668+
suffix = "%s%+03d" % (exponent_indicator, exponent + point_pos)
669+
else:
670+
suffix = ""
671+
672+
# String of output digits, padded sufficiently with zeros on the left
673+
# so that we'll have at least one digit before the decimal point.
674+
digits = f"{significand:0{point_pos + 1}d}"
675+
676+
# Before padding, the output has the form f"{sign}{leading}{trailing}",
677+
# where `leading` includes thousands separators if necessary and
678+
# `trailing` includes the decimal separator where appropriate.
679+
sign = "-" if negative else pos_sign
680+
leading = digits[: len(digits) - point_pos]
681+
frac_part = digits[len(digits) - point_pos :]
682+
if trim_zeros:
683+
frac_part = frac_part.rstrip("0")
684+
separator = "" if trim_point and not frac_part else "."
685+
trailing = separator + frac_part + suffix
686+
687+
# Do zero padding if required.
688+
if zeropad:
689+
min_leading = minimumwidth - len(sign) - len(trailing)
690+
# When adding thousands separators, they'll be added to the
691+
# zero-padded portion too, so we need to compensate.
692+
leading = leading.zfill(
693+
3 * min_leading // 4 + 1 if thousands_sep else min_leading
694+
)
695+
696+
# Insert thousands separators if required.
697+
if thousands_sep:
698+
first_pos = 1 + (len(leading) - 1) % 3
699+
leading = leading[:first_pos] + "".join([
700+
thousands_sep + leading[pos : pos + 3]
701+
for pos in range(first_pos, len(leading), 3)
702+
])
703+
704+
# We now have a sign and a body. Pad with fill character if necessary
705+
# and return.
706+
body = leading + trailing
707+
padding = fill * (minimumwidth - len(sign) - len(body))
708+
if align == ">":
709+
return padding + sign + body
710+
elif align == "<":
711+
return sign + body + padding
712+
elif align == "^":
713+
half = len(padding) // 2
714+
return padding[:half] + sign + body + padding[half:]
715+
else: # align == "="
716+
return sign + padding + body
717+
501718
def __add__(a, b):
502719
"""a + b"""
503720
return forward(a, b, _add, _math_op_add)
@@ -1211,7 +1428,9 @@ cdef enum ParserState:
12111428

12121429
cdef _raise_invalid_input(s):
12131430
s = repr(s)
1214-
if s[0] == 'b':
1431+
if s[:2] in ('b"', "b'"):
1432+
s = s[1:]
1433+
elif PY_MAJOR_VERSION ==2 and s[:2] in ('u"', "u'"):
12151434
s = s[1:]
12161435
raise ValueError(f'Invalid literal for Fraction: {s}') from None
12171436

0 commit comments

Comments
 (0)