]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-67790: Support basic formatting for Fraction (#111320)
authorMark Dickinson <dickinsm@gmail.com>
Sat, 16 Dec 2023 10:58:31 +0000 (10:58 +0000)
committerGitHub <noreply@github.com>
Sat, 16 Dec 2023 10:58:31 +0000 (10:58 +0000)
PR #100161 added fancy float-style formatting for the Fraction type,
but left us in a state where basic formatting for fractions (alignment,
fill, minimum width, thousands separators) still wasn't supported.

This PR adds that support.

---------

Co-authored-by: Serhiy Storchaka <storchaka@gmail.com>
Doc/library/fractions.rst
Doc/whatsnew/3.13.rst
Lib/fractions.py
Lib/test/test_fractions.py
Misc/NEWS.d/next/Library/2023-10-25-13-07-53.gh-issue-67790.jMn9Ad.rst [new file with mode: 0644]

index 509c63686f5a7fd7b8cfcbb2222307c19ef77b15..887c3844d20faa769060150aaddd2ce370e7226d 100644 (file)
@@ -106,6 +106,10 @@ another rational number, or from a string.
       presentation types ``"e"``, ``"E"``, ``"f"``, ``"F"``, ``"g"``, ``"G"``
       and ``"%""``.
 
+   .. versionchanged:: 3.13
+      Formatting of :class:`Fraction` instances without a presentation type
+      now supports fill, alignment, sign handling, minimum width and grouping.
+
    .. attribute:: numerator
 
       Numerator of the Fraction in lowest term.
@@ -201,17 +205,36 @@ another rational number, or from a string.
 
    .. 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.
+      Provides support for formatting of :class:`Fraction` instances via the
+      :meth:`str.format` method, the :func:`format` built-in function, or
+      :ref:`Formatted string literals <f-strings>`.
+
+      If the ``format_spec`` format specification string does not end with one
+      of the presentation types ``'e'``, ``'E'``, ``'f'``, ``'F'``, ``'g'``,
+      ``'G'`` or ``'%'`` then formatting follows the general rules for fill,
+      alignment, sign handling, minimum width, and grouping as described in the
+      :ref:`format specification mini-language <formatspec>`. The "alternate
+      form" flag ``'#'`` is supported: if present, it forces the output string
+      to always include an explicit denominator, even when the value being
+      formatted is an exact integer. The zero-fill flag ``'0'`` is not
+      supported.
+
+      If the ``format_spec`` format specification string ends with one of
+      the presentation types ``'e'``, ``'E'``, ``'f'``, ``'F'``, ``'g'``,
+      ``'G'`` or ``'%'`` then formatting follows the rules outlined for the
+      :class:`float` type in the :ref:`formatspec` section.
 
       Here are some examples::
 
          >>> from fractions import Fraction
+         >>> format(Fraction(103993, 33102), '_')
+         '103_993/33_102'
+         >>> format(Fraction(1, 7), '.^+10')
+         '...+1/7...'
+         >>> format(Fraction(3, 1), '')
+         '3'
+         >>> format(Fraction(3, 1), '#')
+         '3/1'
          >>> format(Fraction(1, 7), '.40g')
          '0.1428571428571428571428571428571428571429'
          >>> format(Fraction('1234567.855'), '_.2f')
index 4f9643967d20cfa63fb96300121ef8f949d7ef0c..ce4f66b97a0be424dd11debfcb97b393129858f0 100644 (file)
@@ -212,6 +212,14 @@ email
   (Contributed by Thomas Dwyer and Victor Stinner for :gh:`102988` to improve
   the CVE-2023-27043 fix.)
 
+fractions
+---------
+
+* Formatting for objects of type :class:`fractions.Fraction` now supports
+  the standard format specification mini-language rules for fill, alignment,
+  sign handling, minimum width and grouping. (Contributed by Mark Dickinson
+  in :gh:`111320`)
+
 glob
 ----
 
index c95db0730e5b6d2397fe7722681a70e71b6fc472..6532d5d54e3c351fe29ea74c135c7cd0b7dc9433 100644 (file)
@@ -139,6 +139,23 @@ def _round_to_figures(n, d, figures):
     return sign, significand, exponent
 
 
+# Pattern for matching non-float-style format specifications.
+_GENERAL_FORMAT_SPECIFICATION_MATCHER = re.compile(r"""
+    (?:
+        (?P<fill>.)?
+        (?P<align>[<>=^])
+    )?
+    (?P<sign>[-+ ]?)
+    # Alt flag forces a slash and denominator in the output, even for
+    # integer-valued Fraction objects.
+    (?P<alt>\#)?
+    # We don't implement the zeropad flag since there's no single obvious way
+    # to interpret it.
+    (?P<minimumwidth>0|[1-9][0-9]*)?
+    (?P<thousands_sep>[,_])?
+""", re.DOTALL | re.VERBOSE).fullmatch
+
+
 # Pattern for matching float-style format specifications;
 # supports 'e', 'E', 'f', 'F', 'g', 'G' and '%' presentation types.
 _FLOAT_FORMAT_SPECIFICATION_MATCHER = re.compile(r"""
@@ -414,27 +431,42 @@ class Fraction(numbers.Rational):
         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)
+    def _format_general(self, match):
+        """Helper method for __format__.
 
+        Handles fill, alignment, signs, and thousands separators in the
+        case of no presentation type.
+        """
         # 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"]
+        alternate_form = bool(match["alt"])
+        minimumwidth = int(match["minimumwidth"] or "0")
+        thousands_sep = match["thousands_sep"] or ''
+
+        # Determine the body and sign representation.
+        n, d = self._numerator, self._denominator
+        if d > 1 or alternate_form:
+            body = f"{abs(n):{thousands_sep}}/{d:{thousands_sep}}"
+        else:
+            body = f"{abs(n):{thousands_sep}}"
+        sign = '-' if n < 0 else pos_sign
+
+        # 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 _format_float_style(self, match):
+        """Helper method for __format__; handles float presentation types."""
         fill = match["fill"] or " "
         align = match["align"] or ">"
         pos_sign = "" if match["sign"] == "-" else match["sign"]
@@ -530,6 +562,23 @@ class Fraction(numbers.Rational):
         else:  # align == "="
             return sign + padding + body
 
+    def __format__(self, format_spec, /):
+        """Format this fraction according to the given format specification."""
+
+        if match := _GENERAL_FORMAT_SPECIFICATION_MATCHER(format_spec):
+            return self._format_general(match)
+
+        if match := _FLOAT_FORMAT_SPECIFICATION_MATCHER(format_spec):
+            # Refuse the temptation to guess if both alignment _and_
+            # zero padding are specified.
+            if match["align"] is None or match["zeropad"] is None:
+                return self._format_float_style(match)
+
+        raise ValueError(
+            f"Invalid format specifier {format_spec!r} "
+            f"for object of type {type(self).__name__!r}"
+        )
+
     def _operator_fallbacks(monomorphic_operator, fallback_operator):
         """Generates forward and reverse operators given a purely-rational
         operator and a function from the operator module.
index 499e3b6e656faa96f6d54d02c5054a7365516225..84779526ce0eb071a9b2ac18485de0d8e9e0cb2b 100644 (file)
@@ -849,12 +849,50 @@ class FractionTest(unittest.TestCase):
         self.assertEqual(type(f.denominator), myint)
 
     def test_format_no_presentation_type(self):
-        # Triples (fraction, specification, expected_result)
+        # Triples (fraction, specification, expected_result).
         testcases = [
-            (F(1, 3), '', '1/3'),
-            (F(-1, 3), '', '-1/3'),
-            (F(3), '', '3'),
-            (F(-3), '', '-3'),
+            # Explicit sign handling
+            (F(2, 3), '+', '+2/3'),
+            (F(-2, 3), '+', '-2/3'),
+            (F(3), '+', '+3'),
+            (F(-3), '+', '-3'),
+            (F(2, 3), ' ', ' 2/3'),
+            (F(-2, 3), ' ', '-2/3'),
+            (F(3), ' ', ' 3'),
+            (F(-3), ' ', '-3'),
+            (F(2, 3), '-', '2/3'),
+            (F(-2, 3), '-', '-2/3'),
+            (F(3), '-', '3'),
+            (F(-3), '-', '-3'),
+            # Padding
+            (F(0), '5', '    0'),
+            (F(2, 3), '5', '  2/3'),
+            (F(-2, 3), '5', ' -2/3'),
+            (F(2, 3), '0', '2/3'),
+            (F(2, 3), '1', '2/3'),
+            (F(2, 3), '2', '2/3'),
+            # Alignment
+            (F(2, 3), '<5', '2/3  '),
+            (F(2, 3), '>5', '  2/3'),
+            (F(2, 3), '^5', ' 2/3 '),
+            (F(2, 3), '=5', '  2/3'),
+            (F(-2, 3), '<5', '-2/3 '),
+            (F(-2, 3), '>5', ' -2/3'),
+            (F(-2, 3), '^5', '-2/3 '),
+            (F(-2, 3), '=5', '- 2/3'),
+            # Fill
+            (F(2, 3), 'X>5', 'XX2/3'),
+            (F(-2, 3), '.<5', '-2/3.'),
+            (F(-2, 3), '\n^6', '\n-2/3\n'),
+            # Thousands separators
+            (F(1234, 5679), ',', '1,234/5,679'),
+            (F(-1234, 5679), '_', '-1_234/5_679'),
+            (F(1234567), '_', '1_234_567'),
+            (F(-1234567), ',', '-1,234,567'),
+            # Alternate form forces a slash in the output
+            (F(123), '#', '123/1'),
+            (F(-123), '#', '-123/1'),
+            (F(0), '#', '0/1'),
         ]
         for fraction, spec, expected in testcases:
             with self.subTest(fraction=fraction, spec=spec):
@@ -1218,6 +1256,10 @@ class FractionTest(unittest.TestCase):
             '.%',
             # Z instead of z for negative zero suppression
             'Z.2f'
+            # z flag not supported for general formatting
+            'z',
+            # zero padding not supported for general formatting
+            '05',
         ]
         for spec in invalid_specs:
             with self.subTest(spec=spec):
diff --git a/Misc/NEWS.d/next/Library/2023-10-25-13-07-53.gh-issue-67790.jMn9Ad.rst b/Misc/NEWS.d/next/Library/2023-10-25-13-07-53.gh-issue-67790.jMn9Ad.rst
new file mode 100644 (file)
index 0000000..44c5702
--- /dev/null
@@ -0,0 +1,2 @@
+Implement basic formatting support (minimum width, alignment, fill) for
+:class:`fractions.Fraction`.