Skip to content

[NetBSD] float formatting change between Python 3.11 and 3.12? #128005

Closed as not planned

Description

@0-wiz-0

Bug report

Bug description:

While looking at a ntpsec test failure, I looked at the Python self tests and noticed that some are failing on NetBSD 10/amd64.
I extraced and minimized one test case based on test_format:

#!/usr/bin/env python


def testformat(formatstr, args, output=None, limit=None):
    result = formatstr % args
    if output and limit is None and result != output:
        raise AssertionError("%r %% %r == %r != %r" %
                             (formatstr, args, result, output))
    # when 'limit' is specified, it determines how many characters
    # must match exactly; lengths must always match.
    # ex: limit=5, '12345678' matches '12345___'
    # (mainly for floating-point format tests for which an exact match
    # can't be guaranteed due to rounding and representation errors)
    elif output and limit is not None and (
            len(result) != len(output) or result[:limit] != output[:limit]):
        print("%s %% %s == %s != %s" %
              (repr(formatstr), repr(args), repr(result), repr(output)))


testformat(b"%a", (3.14,), b"3.14")

This test fails with Python 3.13.1 and Python 3.12.8:

Traceback (most recent call last):
  File "/home/wiz/./bin/test-format.py", line 20, in <module>
    testformat(b"%a", (3.14,), b"3.14")
    ~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/wiz/./bin/test-format.py", line 7, in testformat
    raise AssertionError("%r %% %r == %r != %r" %
                         (formatstr, args, result, output))
AssertionError: b'%a' % (3.1400000000000001,) == b'3.1400000000000001' != b'3.14'

but works fine with Python 3.10.15 and 3.11.11 in the same environment.
(All software compiled from pkgsrc in case it matters.)

Initially I suspected a bug in NetBSD's printf of float handling, but since this works with Python 3.11.11 I'm not sure any longer.

CPython versions tested on:

3.10, 3.11, 3.12, 3.13

Operating systems tested on:

Other

Activity

  1. ericvsmith commented on Dec 17, 2024

    @ericvsmith
    Member

    What are the values of sys.float_info and sys.float_repr_style on the various Pythons you're testing?

  2. alliesw commented on Dec 17, 2024

    @alliesw

    So basically the %a format specifier outputs the full precision, which is why you're getting b'3.1400000000000001' instead of b'3.14' and you're calling testformat(b"%a", (3.14,), b"3.14"), which formats the floating-point number 3.14 using %a in bytes format.

    So I THINK the assertion fails because the result of the format (b'3.1400000000000001') does not match the expected output (b'3.14').

    The floating-point representation format with %a is too precise even when you round it, even though you might expect it to show only 3.14 (as it would with %f)..... Unless you need the hexadecimal outputs, you gotta use round() to limit the precision before formatting, or obv just adjust the formatting you have according to what you want output. If you're trying to format floating-point numbers with decimal places, use %f instead of %a.

    Bottom-line: Try removing the %a! (You've prob tried this - but if I can some more context I would love to help)

  3. garyemiller commented on Dec 17, 2024

    @garyemiller

    Gary Miller here, I am working this issue from the NTPSec side. We are unable to reproduce. The test case presented here is an abstraction of some long standing NTPSec tests that are known to pass on every arch we can test on. Except for the OPs.

    On Gentoo amd64, python 3.13.1t:

    >>> import sys
    >>> sys.float_info
    sys.float_info(max=1.7976931348623157e+308, max_exp=1024, max_10_exp=308, min=2.2250738585072014e-308, min_exp=-1021, min_10_exp=-307, dig=15, mant_dig=53, epsilon=2.220446049250313e-16, radix=2, rounds=1)
    >>> sys.float_repr_style
    'short'
    

    Similar Python 3.10.14 and up. For me.

  4. skirpichev commented on Dec 17, 2024

    @skirpichev
    Member

    On Gentoo amd64, python 3.13.1t [...]

    Looks like ordinary IEEE doubles. If it's info not from the OP's system, where this issue happens, hardly it's relevant.

    %a is too precise even when you round it, even though you might expect it to show only 3.14 (as it would with %f)..... Unless you need the hexadecimal outputs

    Python doesn't support hexadecimal output via old-style formatting nor with format() or f-strings. "%a" means string conversion with ascii():

    >>> "%a" % 3.14
    '3.14'
    >>> ascii(3.14)
    '3.14'

    It's same as str/repr for floats.

  5. alliesw commented on Dec 17, 2024

    @alliesw

    So it seems that you're possibly dealing with an issue related to floating-point formatting in Python 3.13.1, which might be affecting your NTPSec-side code. Specifically, the default floating-point formatting behavior (sys.float_repr_style = 'short') and the fact that floating-point numbers in Python use double precision which can lead to issues with how numbers are displayed or handled.

    From what I gathered: Python 3.13.1 uses double-precision floating-point numbers as standard (sys.float_info), which means numbers like 3.14 may not be represented exactly as 3.14 but as something like 3.1400000000000001. This can cause issues if you're expecting exact decimal representation in your output or in calculations.
    Additionally, Python's default short format (sys.float_repr_style = 'short') provides a compact, potentially scientific-notation based string representation for floating-point numbers, which could lead to discrepancies in output formatting.

    If the NTPSec code (or your code interacting with it) is producing inconsistent results with floating-point numbers, you can try rounding the numbers like I said, or use the decimal.Decimal class? So for example:
    Let’s assume that the NTPSec code (or your code that interacts with NTPSec) is having issues formatting floats or handling them in a consistent way (which I think is what you're getting at?)

    To ensure compatibility with Python 3.13.1 and avoid floating-point inaccuracies:

    import sys
    from decimal import Decimal

    Step 1: Round floats to avoid precision issues

    value = 3.1400000000000001
    rounded_value = round(value, 2)

    Step 2: Alternatively, use Decimal for precise decimal handling

    value_decimal = Decimal('3.14')

    Step 3: Format for output

    formatted_value = "{:.2f}".format(rounded_value) # or use "%.2f" % rounded_value
    print(formatted_value) # Output: 3.14

    For scientific notation handling, you can also use:

    scientific_value = "{:e}".format(3.1400000000000001)
    print(scientific_value) # Output: 3.140000e+00

  6. garyemiller commented on Dec 17, 2024

    @garyemiller

    Looks like ordinary IEEE doubles. If it's info not from the OP's system, where this issue happens, hardly it's relevant.

    I mention it because some people, elsewhere, have thought this a common bug, just trying to narrow it back down again. It is not just "3.11 and 3.12", it is only a very specific 3.12 that files.

    The %a is a read herring. The bug NTPsec is seeing is in formatting %.2f. When the test does:

    "%.2f" % 12.34

    Instead of "12.34", we get "12.345999999999999"

  7. alliesw commented on Dec 17, 2024

    @alliesw

    On Gentoo amd64, python 3.13.1t [...]

    Looks like ordinary IEEE doubles. If it's info not from the OP's system, where this issue happens, hardly it's relevant.

    %a is too precise even when you round it, even though you might expect it to show only 3.14 (as it would with %f)..... Unless you need the hexadecimal outputs

    Python doesn't support hexadecimal output via old-style formatting nor with format() or f-strings. "%a" means string conversion with ascii():

    >>> "%a" % 3.14
    '3.14'
    >>> ascii(3.14)
    '3.14'
    

    It's same as str/repr for floats.

    %a converts the value to a string using the repr() function, which is meant to produce a string representation of the object that can (ideally) be used to recreate the object. For floating-point numbers, it will output the number in hexadecimal floating-point notation..... No, %a does not refer to string conversion using ascii(). Instead, %a is the format specifier for repr() in Python?

    num = 3.14
    print("%a" % num)
    '3.1400000000000001'
    This shows the repr() of 3.14 with full precision. If you use f-strings, it’s equivalent to calling repr():
    << print(f"{num!a}")
    '3.1400000000000001'

    I could be looking at old current documentation only applicable to 3.13.1 (I'm going to review the documentation you sent right now)

  8. garyemiller commented on Dec 17, 2024

    @garyemiller

    So it seems that you're possibly dealing with an issue related to floating-point formatting in Python 3.13.1,

    Don't fall into the generalization trap. The vast majority of 3.13.1 passes the NTPSec tests just fine. Just this one particular NETBSD has shown the issue.

    From what I gathered: Python 3.13.1 uses double-precision floating-point numbers as standard (sys.float_info), which means numbers like 3.14 may not be represented exactly as 3.14 but as something like 3.1400000000000001.

    Except the "%.2f" is supposed to force only 2 digits past the decimal.

    NTPSec already uses your "Step 3".

  9. garyemiller commented on Dec 17, 2024

    @garyemiller

    Please forget "%a" was ever mentioned. The original problem is with "%.2f"

  10. alliesw commented on Dec 17, 2024

    @alliesw

    Please forget "%a" was ever mentioned. The original problem is with "%.2f"

    Okay, fully understood now (to the extent I can see from my side at least lol)... and now I'm at a slight loss... last 3 recommendations:

    1. To control the precision or format of floating-point numbers, use string formatting (f"{num}", f"{num:.2f}", f"{num:.2e}", or f"{num!a}") rather than sys.float_repr_style.

    2. Round the number BEFORE formatting (using round())

    3. Use Decimal type to avoid floating-point precision issues?

    import sys
    from decimal import Decimal

    num = Decimal('12.34')
    print(f"{num:.2f}")
    '12.34'

    Except you don't have this issue on other versions...... what the heck

  11. alliesw commented on Dec 17, 2024

    @alliesw

    I mean if you set sys.float_repr_style = 'legacy', it would effect the string representation of floats when printing.... allowing it HOPEFULLY return to normal? just not actually fix your underlying floating-point precision issue....

  12. garyemiller commented on Dec 17, 2024

    @garyemiller

    last 3 recommendations:

    NTPSec works on all Python versions from 2.6 to the latest.

    1. f-strings are 3.6 and up.
    2. we already round()
    3. Decimals have never been needed before.

    Since 2 should solve the problem, but does not, the problem is prolly elsewhere.

    I think we are on the same page now. Just need the OP to fix his test case, and post the requested sys.float_info and sys.float_repr_style.

    UNtil I can duplicate it, I can't code a work around.

  13. alliesw commented on Dec 17, 2024

    @alliesw

    Okay I reviewed the original file and ran it:
    To support Python 3.13.1 and fix the floating-point formatting issues in NTPsec, you can:

    • Round explicitly using round(num, 2) before formatting the number with %.2f.
    • Use decimal.Decimal if you need more precise control over floating-point operations (especially when dealing with time or monetary values).
      **Adjust the test tolerance to account for small floating-point errors if those differences are acceptable.
      **Refactor your code to ensure consistency in rounding and formatting.

    For example, in your test_summary test case, you could adjust the formatting as follows:

    from decimal import Decimal, ROUND_HALF_UP

    Assuming data is the value that needs formatting

    data = 1.2345999999999999

    Round and format the value with Decimal

    formatted_data = Decimal(data).quantize(Decimal('0.0001'), rounding=ROUND_HALF_UP)

    Now you can format it like this

    formatted_string = f"{formatted_data:.4f}"

    Expected output is '1.2346', now you can assert:

    self.assertEqual(formatted_string, '1.2346')

  14. alliesw commented on Dec 17, 2024

    @alliesw

    The failures are related to the rounding behavior of floating-point numbers, specifically:

    Test test_summary: The failure is because Python is formatting 1.2345999999999999 as 1.2346 (but the expected output is 1.2346).
    Test test_rfc3339: The failure is due to Python representing 1480999786.025 as 2016-12-06T04:49:46.0250001Z instead of the expected 2016-12-06T04:49:46.025Z.
    Test test_stringfiltcooker: Similar floating-point precision issues are causing it to represent numbers slightly differently (0.00068000000000000005 vs. 0.00068).
    Test test_unitify: The failure is due to the number being rounded to 12.34ppm instead of 12.35ppm.

  15. 10 remaining items

  16. skirpichev commented on Dec 17, 2024

    @skirpichev
    Member

    IIUC, the test was extracted from test_format.py:

    testcommon(b"%a", 3.14, b"3.14")

    It's coming from test_bytes_and_bytearray_format(), which it's not prefixed with something like

    @unittest.skipUnless(getattr(sys, 'float_repr_style', '') == 'short'

    So, it will fail. But I suspect many other tests from that file will fail as well. For example:

    self.assertEqual(format(12.3, "\u2007<6"), "12.3\u2007\u2007")

    We have to decide:

    1. leave this as is
    2. skip these tests (maybe the whole file) for non-IEEE systems
    3. fix them to use exactly representable floats, e.g. 1.25.

    Edit:
    BTW, it looks we don't have any build bots, that works with #define _PY_SHORT_FLOAT_REPR 0, i.e. ("legacy" repr for floats).

  17. changed the title [-]float formatting change between Python 3.11 and 3.12?[/-] [+][NetBSD] float formatting change between Python 3.11 and 3.12?[/+] on Dec 17, 2024
  18. vstinner commented on Dec 17, 2024

    @vstinner
    Member

    When I fix the configure patch in pkgsrc, the output is as expected

    So it was a bug in NetBSD package? A NetBSD patch caused sys.float_repr_style to be "legacy"?

  19. vstinner commented on Dec 17, 2024

    @vstinner
    Member

    BTW, it looks we don't have any build bots, that works with #define _PY_SHORT_FLOAT_REPR 0, i.e. ("legacy" repr for floats).

    All PEP 11 platforms support the floatting point "short" format.

  20. vstinner commented on Dec 17, 2024

    @vstinner
    Member

    skip these tests (maybe the whole file) for non-IEEE systems

    I built Python with "legacy" formatting:

    diff --git a/Include/internal/pycore_pymath.h b/Include/internal/pycore_pymath.h
    index eea8996ba68..c64e5d87932 100644
    --- a/Include/internal/pycore_pymath.h
    +++ b/Include/internal/pycore_pymath.h
    @@ -181,11 +181,7 @@ extern void _Py_set_387controlword(unsigned short);
     // (2) we're on x86 with the rounding precision set to 64-bits
     //     (extended precision), and we don't know how to change
     //     the rounding precision.
    -#if !defined(DOUBLE_IS_LITTLE_ENDIAN_IEEE754) && \
    -    !defined(DOUBLE_IS_BIG_ENDIAN_IEEE754) && \
    -    !defined(DOUBLE_IS_ARM_MIXED_ENDIAN_IEEE754)
    -#  define _PY_SHORT_FLOAT_REPR 0
    -#endif
    +#define _PY_SHORT_FLOAT_REPR 0
     
     // Double rounding is symptomatic of use of extended precision on x86.
     // If we're seeing double rounding, and we don't have any mechanism available

    Only 2 tests of test_format are failing:

    • test_bytes_and_bytearray_format: AssertionError: b'%a' % (3.1400000000000001,) == b'3.1400000000000001' != b'3.14'
    • test_non_ascii: AssertionError: '12.300000000000001' != '12.3\u2007\u2007'
  21. 0-wiz-0 commented on Dec 17, 2024

    @0-wiz-0
    ContributorAuthor

    When I fix the configure patch in pkgsrc, the output is as expected

    So it was a bug in NetBSD package? A NetBSD patch caused sys.float_repr_style to be "legacy"?

    A pkgsrc patch, yes, the chunk starting here wasn't updated when the compile check was changed to a link check:
    https://github.com/NetBSD/pkgsrc/blob/f1f5ea281ff130a99da13a0e91659e4018798283/lang/python313/patches/patch-configure#L37

  22. 0-wiz-0 commented on Dec 17, 2024

    @0-wiz-0
    ContributorAuthor

    The change for legacy -> short also fixed this problem for me:

     FAIL: fromhex (builtins.float)
     Doctest: builtins.float.fromhex
     ----------------------------------------------------------------------
     Traceback (most recent call last):
       File "/scratch/lang/python313/work/Python-3.13.1/Lib/doctest.py", line 2327, in runTest
         raise self.failureException(self.format_failure(new.getvalue()))
     AssertionError: Failed doctest test for builtins.float.fromhex
       File "builtins", line unknown line number, in fromhex
    
     ----------------------------------------------------------------------
     File "builtins", line ?, in builtins.float.fromhex
     Failed example:
         float.fromhex('-0x1p-1074')
     Expected:
         -5e-324
     Got:
         -4.9406564584124654e-324
    

    and this one:

     FAIL: test_g_format_has_no_trailing_zeros (test.test_format.FormatTest.test_g_format_has_no_trailing_zeros)
     ----------------------------------------------------------------------
     Traceback (most recent call last):
       File "/scratch/lang/python313/work/Python-3.13.1/Lib/test/test_format.py", line 495, in test_g_format_has_no_trailing_zeros
         self.assertEqual("%.3g" % 1505.0, "1.5e+03")
         ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^
     AssertionError: '1.50e+03' != '1.5e+03'
     - 1.50e+03
     ?    -
     + 1.5e+03
    
    
    

    and this one:

       File "/scratch/lang/python313/work/Python-3.13.1/Lib/test/test_float.py", line 737, in test_format_testfile
         self.assertEqual(fmt % f, rhs)
         ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^
     AssertionError: '0.029999999999999999' != '0.03'
     - 0.029999999999999999
     + 0.03
    
    

    and this one:

     FAIL: test_format_misc (test.test_peepholer.TestTranforms.test_format_misc)
     ----------------------------------------------------------------------
     Traceback (most recent call last):
       File "/scratch/lang/python313/work/Python-3.13.1/Lib/test/test_peepholer.py", line 565, in test_format_misc
         self.assertEqual(format('x = %s!', 1234.5678901), 'x = 1234.5678901!')
         ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
     AssertionError: 'x = 1234.5678901000001!' != 'x = 1234.5678901!'
     - x = 1234.5678901000001!
     ?                 ------
     + x = 1234.5678901!
    

    and these:

     ======================================================================
     FAIL: test_defaults_keyword (test.test_configparser.ConfigParserTestCase.test_defaults_keyword)
     bpo-23835 fix for ConfigParser
     ----------------------------------------------------------------------
     Traceback (most recent call last):
       File "/scratch/lang/python313/work/Python-3.13.1/Lib/test/test_configparser.py", line 990, in test_defaults_keyword
         self.assertEqual(cf[self.default_section]['1'], '2.4')
         ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
     AssertionError: '2.3999999999999999' != '2.4'
     - 2.3999999999999999
     + 2.4
    
    
     ======================================================================
     FAIL: test_defaults_keyword (test.test_configparser.ConfigParserTestCaseNoValue.test_defaults_keyword)
     bpo-23835 fix for ConfigParser
     ----------------------------------------------------------------------
     Traceback (most recent call last):
       File "/scratch/lang/python313/work/Python-3.13.1/Lib/test/test_configparser.py", line 990, in test_defaults_keyword
         self.assertEqual(cf[self.default_section]['1'], '2.4')
         ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
     AssertionError: '2.3999999999999999' != '2.4'
     - 2.3999999999999999
     + 2.4
    
    
     ======================================================================
     FAIL: test_defaults_keyword (test.test_configparser.ConfigParserTestCaseNonStandardDefaultSection.test_defaults_keyword)
     bpo-23835 fix for ConfigParser
     ----------------------------------------------------------------------
     Traceback (most recent call last):
       File "/scratch/lang/python313/work/Python-3.13.1/Lib/test/test_configparser.py", line 990, in test_defaults_keyword
         self.assertEqual(cf[self.default_section]['1'], '2.4')
         ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
     AssertionError: '2.3999999999999999' != '2.4'
     - 2.3999999999999999
     + 2.4
    
    
     ======================================================================
     FAIL: test_defaults_keyword (test.test_configparser.ConfigParserTestCaseNonStandardDelimiters.test_defaults_keyword)
     bpo-23835 fix for ConfigParser
     ----------------------------------------------------------------------
     Traceback (most recent call last):
       File "/scratch/lang/python313/work/Python-3.13.1/Lib/test/test_configparser.py", line 990, in test_defaults_keyword
         self.assertEqual(cf[self.default_section]['1'], '2.4')
         ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
     AssertionError: '2.3999999999999999' != '2.4'
     - 2.3999999999999999
     + 2.4
    
    

    and this one:

     FAIL: SequenceMatcher (difflib)
     Doctest: difflib.SequenceMatcher
     ----------------------------------------------------------------------
     Traceback (most recent call last):
       File "/scratch/lang/python313/work/Python-3.13.1/Lib/doctest.py", line 2327, in runTest
         raise self.failureException(self.format_failure(new.getvalue()))
     AssertionError: Failed doctest test for difflib.SequenceMatcher
       File "/scratch/lang/python313/work/Python-3.13.1/Lib/difflib.py", line 45, in SequenceMatcher
    
     ----------------------------------------------------------------------
     File "/scratch/lang/python313/work/Python-3.13.1/Lib/difflib.py", line 81, in difflib.SequenceMatcher
     Failed example:
         print(round(s.ratio(), 3))
     Expected:
         0.866
     Got:
         0.86599999999999999
    

    and this one:

     FAIL: test_format (test.test_str.StrTest.test_format)
     ----------------------------------------------------------------------
     Traceback (most recent call last):
       File "/scratch/lang/python313/work/Python-3.13.1/Lib/test/test_str.py", line 1224, in test_format
         self.assertEqual('{0:\x00<6}'.format(3.14), '3.14\x00\x00')
         ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
     AssertionError: '3.1400000000000001' != '3.14\x00\x00'
     - 3.1400000000000001
     + 3.14^@^@
    
    
    

    and these:

     ----------------------------------------------------------------------
     File "/scratch/lang/python313/work/Python-3.13.1/Doc/howto/enum.rst", line 1529, in enum.rst
     Failed example:
         Planet.EARTH.value
     Expected:
         (5.976e+24, 6378140.0)
     Got:
         (5.9760000000000004e+24, 6378140.0)
     ----------------------------------------------------------------------
     File "/scratch/lang/python313/work/Python-3.13.1/Doc/howto/enum.rst", line 1531, in enum.rst
     Failed example:
         Planet.EARTH.surface_gravity
     Expected:
         9.802652743337129
     Got:
         9.8026527433371289
    

    and some more, tired of copying & pasting.
    Is this a bug in strtod() on NetBSD, or just an implementation difference?

  23. ericvsmith commented on Dec 17, 2024

    @ericvsmith
    Member

    Is this a bug in strtod() on NetBSD, or just an implementation difference?

    I think it's just the difference between short float repr and legacy float repr. I don't think there's any bug here, except possibly @skirpichev's point about cleaning up the tests to work with legacy float repr. And if we do that, it should be a separate issue, so I'm going to close this.

  24. removed their assignment
    on Dec 18, 2024
  25. skirpichev commented on Dec 18, 2024

    @skirpichev
    Member

    it should be a separate issue

    See #128051.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    testsTests in the Lib/test dirtype-bugAn unexpected behavior, bug, or error

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions