Support fractions in string formatting

Feature or enhancement

Support fraction.Fraction both in printf-style formatting and new-style formatting. Currently, the former converts to float and the latter is unsupported. The following example has been run on a platform with rounding behaviour “to nearest, ties to even”:

>>> from fractions import Fraction
>>> f = Fraction(2635, 1000)
>>> '%.2f' %f
'2.63'
>>> '{:.2f}'.format(f)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unsupported format string passed to Fraction.__format__

Python rounds down here because 2635/1000 as float is 2.6349999999999997868371792719699442386627197265625. The output should not depend on floating point representations.

Pitch

Currently, it is difficult to obtain rounded decimal output from fractions of the fractions module. The best way without writing my own rounding function I could find is to use Decimal(f.numerator) / Decimal(f.denominator) after assigning the desired precision to getcontext().prec. If output contains numbers with different precisions, one must prepare parts of the output in variables and assemble the output as one must change getcontext().prec in between. This is awkward.

2 Likes

+1 for support in new-style formatting (i.e., .format method and f-strings); this is something I’ve often wanted, and even started to implement on a couple of occasions. Caution: it’s not a small task to get everything right.

Support in %-based formatting is likely a no-go, I’m afraid. Unlike format, there isn’t any extensibility mechanism for custom classes to override the behaviour of %-based formatting (and adding such a mechanism would be a large and questionable undertaking).

4 Likes

I also support Fraction.__format__. I’m not sure if it should be like complex, where I made it use the normal float spec, but applied to both parts, or a separate spec language that would cater to Fraction-specific needs. I’m not sure complex was a great idea, and it’s surely not used much.

  • If it is an integer format type, e.g. d or x, it makes sense to display numerator and denominator in the specified format separated by a fraction slash U+2044 (though not much in case of c). See also How to display the fraction 15/16 nicely in Unicode? - Stack Overflow

  • For types currently defined for floats and Decimals, e.g. e, f or g, it seems highly unlikely that users would want to apply the format separately to numerator and denominator as these are integers, producing e.g. 100.0%/300.0% for '{:.1%}'.format(Fraction(1,3)). It makes much more sense to display a decimal fraction.

  • For type s, one might want to prefer a Unicode character such as ¾ (U+00BE) where available and fall back to d otherwise.

  • Type None, should probably do the same as s or d.

Trying to add a __format__() function to the Fraction class that delegates the f type formatting to Decimal.__format__(), I now see that setting decimal.getcontext().prec doesn’t count after the decimal point but all digits starting from the first non-zero digit. This causes problems for implementing cases like '{:.1f}'.format(Fraction(1,100)) where we want 0.0 but Decimal expects to produce at least one non-zero digit. If we just ask for 1 digit, we run into problems for Fraction(51,1000) as Decimal first rounds to 0.05 and then, applying the format spec to Decimal('0.05'), produces 0.0 (default is to round ties to even) even tough there was no rounding tie and the result should have been 0.1. (With “round ties up” the problem would show for 49/1000.) Mark, is this the problem you encountered?

However, format type e should be easy as one can just set decimal.getcontext().prec to the precision provided in the e format, move the . to have exactly 1 non-zero digit to the left, remove leading zeros and set the exponent according to the movement.

It’s easy to implement selected format specs without decimal.Decimal just with fractions.Fraction as in my workaround but covering all features of the format specification mini-language will take some effort.

1 Like

That’s neat! You should also probably bracket the fraction with U+2064 INVISIBLE PLUS as explained in the comments then? Just in case someone does f"13{some_fraction:d}". (E.g., Unicode Escape / Unescape (Encoder / Decoder) Online - DenCode)

scratches head

But you specified two decimal places using the ‘f’ (float) format code, so of course it has to rely on floating point representation.

It would be disturbing and wrong if '%.2f' % f and '%.2f' % float(f) gave different results.

Fortunately, as Mark explains, we can’t really change or extend printf-style formatting, so I don’t know why I’m even responding :slight_smile:

I would expect integer format codes to require an integer, not a fraction. Integer format codes don’t even work with floats.

You don’t need an integer code to specify that the numerator and denominator are displayed as integers. What else would they display as?

Use of a fraction slash is problematic, as that looks very poor in the terminal with a monospaced typeface. Better to stick to ASCII:

# I don't know how this appears to you, but on my system the first slash is too small

1⁄16 vs 1/16

If people want to replace the slash with U+2044 or U+2215 then can do so themselves.

A strong NO to using pre-composed legacy fraction characters such as ½ U+00BD.

Firstly, they usually look awful in the terminal. There are often typeface issues, such as precomposed fractions having a very slightly different width to other characters even in a monospaced typeface. Some glyphs are completely missing: I can’t display the glyph for 0/3 at all in my terminal, it comes out as a blank space.

Then there is the inconsistent look, with radically different glyphs and column widths: 1/10 has a precomposed character (which doesn’t display on my system!) but 0/10, 3/10, 7/10, 9/10 don’t.

It would be disturbing and wrong if '%.2f' % f and '%.2f' % float(f) gave different results.

Why? “f” doesn’t mean “float” here but fixed-point decimal fraction. Does it also disturb you that the following are different?

>>> '{:.2f}'.format(Decimal('0.015'))
'0.02'
>>> '{:.2f}'.format(float(Decimal('0.015')))
'0.01'

I don’t know what the “official” name of the printf “f” code is, but in practice, it absolutely does mean “convert to a float”:

>>> class MyInt(int):
...     def __float__(self):
...             return 2.5
...
>>>
>>> '%.4f' % MyInt(1000)
'2.5000'

Your examples with the format method are irrelevant, since they are not printf-style string interpolation.

A compromise would be to make code d give the ordinary slash, and another code (say /) give the unicode fraction slash.

It’s not THAT hard to do custom formatting.

f"{f.numerator}\n-\n{f.denominator}"

The default can be aimed at doing what most people want, most of the time, and for anyone who wants something else, format the two parts separately. Or if you need something more elaborate, do the formatting in some other way, maybe stringifying the numerator and then replacing digits in it.

As discussed in the thread, if you’re using the unicode slash you probably want to bracket that with unicode stoppers just in case (f"\u2064{f.numerator}\u2044{f.denominator}\u2064"). So it would be nice to have that whole thing available with a formatter that’s easier to remember than some unicode code points.

I assume at this point the thread has morphed into an April fool’s joke.

3 Likes

This is why I suggested the possibility of a new format spec for fractions: most of the existing format spec language doesn’t apply, or if it does, maybe we want it twice, once for the numerator and once for the denominator. Or maybe just allow “/“ as a format spec to mean “show as fraction” and allow nothing else.

Seconded.

See Implement __format__ for Fraction · Issue #67790 · python/cpython · GitHub. It is not a small task.

Seems unlikely.

Why would somebody need to hard-code the integer part of a mixed fraction instead of taking it from some_fraction?

If they are specifying the whole part and the fraction part individually, why wouldn’t they separate the two parts with a space or whatever separator they want?

The advice to use U+2064 in the comments assumes that the text rendering engine will automatically superscript and subscript the numerator and denominator of the fraction, which seems like a dubious assumption to me, and especially not true when we’re talking about Python in the interactive interpreter.

That gives this Unicode string: “1⁤1⁄234⁤2”. I don’t know what you see, but I see “11/2342” in the browser and “1 1/234 2” in my email.

Unless you control the rendering engine, doing “clever” things with Unicode chars which are likely to be invisible or missing will just end up with a never ending stream of bug reports about unexpected output.

The interactive interpreter is irrelevant here - what matters is where it’s outputting to (ie the console emulator, most likely).

It is not even a small task to decide how you want fractions displayed.

  • Mixed fractions: Fraction(3, 2) → “1 + 1/2” or “1 1/2”?

  • Or just vulgar fractions: 3/2 This already is done (see below).

  • We have a choice between at least one ASCII slash and two Unicode slashes (fraction slash and division slash).

  • Should we try to do something fancy with subscript and superscript digits?

  • What about multiline fraction display?

    x 617

    x -----

    x 977

    (Column of X’s inserted in an attempt to defeat Discuss’ truncation of posts at a row of dashes.)

  • If the fraction is negative, and we’re displaying a mixed fraction, there are multiple options:

    Fraction(-3, 2) could display as:

    -1 - 1/2

    -(1 + 1/2)

    -(1 1/2)

    -2 + 1/2

If we limit ourselves to just one line vulgar fraction displays, I don’t know whether it is worth it. Just use the default string formatting:


>>> from fractions import Fraction

>>> x = Fraction(2, 3)

>>> f'{x}'

'2/3'

So as I see it, there is basic fraction formatting, which already exists, and there is complex fraction formatting, which is too complex and complicated for the format mini-language.

No - the only problem I had was lack of time and energy, given the number of cases that need to be covered.

On implementation: while re-using the existing Decimal formatting may look attractive, I’d recommend avoiding coupling the Fraction formatting to the Decimal formatting and re-implementing from scratch. That said, some of the existing formatting code in _pydecimal.py may be helpful as a starting point, and the existing behaviour for int, float and Decimal should help inform all the little design decisions that need to be made for the many and various corner cases.

This thread seems to have wandered off-trail somewhat; my understanding of the original need was for the f, g, %, and e format specifiers to work for Fraction objects in essentially the same way that they would if the Fraction object were converted to float first, but without suffering the loss of precision that would be incurred from that conversion. That was the specific need that my “something I’m often wanted” comment referred to, and I think it would be a reasonably coherent and self-contained addition for Python 3.12 - other more exotic format specifiers could come later.

Of those format specifiers, the .f format specifier is easiest to implement, so may be a good starting point. I made a POC PR here: bpo-67790: Add '.f' formatting for Fraction objects by mdickinson · Pull Request #1 · mdickinson/cpython · GitHub (the PR is against main on my own fork rather than the python/cpython main branch, since I don’t yet want to bother upstream with this). The PR includes tests, but does not include any documentation changes, and does not go beyond implementing the f-format. It might give a good idea of the size of the problem. (Not small, but not too large either.)

Quick examples:

Python 3.12.0a2+ (heads/fraction-format:8cf8d31ce7, Dec  3 2022, 17:24:40) [Clang 14.0.0 (clang-1400.0.29.202)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from fractions import Fraction as F
>>> f = F(22, 7)
>>> format(f, 'f')
'3.142857'
>>> f'{f:f}'  
'3.142857'
>>> format(f, '.50f')
'3.14285714285714285714285714285714285714285714285714'
>>> format(F('123456789.12345678'), '_.6f')
'123_456_789.123457'
>>> format(f, '020,f')
'0,000,000,003.142857'
>>> format(f, 'X^20f')
'XXXXXX3.142857XXXXXX'
2 Likes

Updated to a PR against the upstream main branch: gh-67790: Support float-style formatting for Fraction instances by mdickinson · Pull Request #100161 · python/cpython · GitHub. It now supports the "e", "f", "g" and "%" presentation types, and all the associated bells and whistles (zero-filling, padding, alignment, alternate forms, negative-zero-suppression, thousands separators, etc.).

@jwagner If you have a chance to kick the tires and let me know whether this works for you, that would be much appreciated.

1 Like