From 3bce02972a45bcab7e83c76478796fd85dcb7709 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Lozier?= Date: Mon, 9 Nov 2026 21:49:42 -0500 Subject: [PATCH] Replace % formatting for floats --- src/core/IronPython/IronPython.csproj | 1 + src/core/IronPython/Runtime/FloatFormatter.cs | 201 ++++++++++++++++++ .../IronPython/Runtime/StringFormatter.cs | 189 ++-------------- tests/suite/stdlib/test_float.py | 1 - tests/suite/test_formatting.py | 48 +---- 5 files changed, 224 insertions(+), 216 deletions(-) create mode 100644 src/core/IronPython/Runtime/FloatFormatter.cs diff --git a/src/core/IronPython/IronPython.csproj b/src/core/IronPython/IronPython.csproj index a8dcf92ed..11f2dbfae 100644 --- a/src/core/IronPython/IronPython.csproj +++ b/src/core/IronPython/IronPython.csproj @@ -16,6 +16,7 @@ T:System.Diagnostics.CodeAnalysis.NotNullIfNotNullAttribute; T:System.Diagnostics.CodeAnalysis.NotNullWhenAttribute; T:System.Index; + M:System.Int32.Parse(System.ReadOnlySpan{System.Char},System.IFormatProvider); T:System.Runtime.Versioning.SupportedOSPlatformAttribute; T:System.Runtime.Versioning.UnsupportedOSPlatformAttribute; M:System.String.Contains(System.Char); diff --git a/src/core/IronPython/Runtime/FloatFormatter.cs b/src/core/IronPython/Runtime/FloatFormatter.cs new file mode 100644 index 000000000..377c2ab80 --- /dev/null +++ b/src/core/IronPython/Runtime/FloatFormatter.cs @@ -0,0 +1,201 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +#nullable enable + +using System; +using System.Diagnostics; +using System.Globalization; +using System.Numerics; +using System.Text; + +namespace IronPython.Runtime { + /// + /// Shared float formatting for % and format() formatting. + /// + internal static class FloatFormatter { + // false for .NET Framework, which only produces 15 significant digits and rounds ties away from zero + private static readonly bool supportsExactDigits = (1.0 / 3).ToString("F17", CultureInfo.InvariantCulture) != "0.33333333333333300"; + + // new in .NET 6 + private static readonly bool supportsPrecisionGreaterThan99 = 0.ToString("D100", CultureInfo.InvariantCulture) != "D100"; + + private static bool UseExactDigits(int precision) => !supportsExactDigits || !supportsPrecisionGreaterThan99 && precision > 99; + + /// + /// Formats the absolute value of ; the caller is responsible for the sign. + /// + /// One of e, E, f, F, g or G. + public static string Format(double value, char type, int precision, bool altForm = false) { + Debug.Assert(precision >= 0); + + value = Math.Abs(value); + bool upper = char.IsUpper(type); + + if (double.IsNaN(value)) return upper ? "NAN" : "nan"; + if (double.IsInfinity(value)) return upper ? "INF" : "inf"; + + switch (type) { + case 'e': + case 'E': { + string mantissa = FormatSignificant(value, precision + 1, out int exp); + if (altForm && precision == 0) mantissa += "."; + return mantissa + FormatExponent(exp, upper); + } + case 'f': + case 'F': { + string res = FormatFixed(value, precision); + if (altForm && precision == 0) res += "."; + return res; + } + case 'g': + case 'G': + return FormatGeneral(value, precision, altForm, upper); + default: + throw new ArgumentOutOfRangeException(nameof(type)); + } + } + + /// + /// Rounds to significant digits and uses exponent notation if the exponent is below -4 or at least the precision. + /// Trailing zeros are removed unless is set. + /// + private static string FormatGeneral(double value, int precision, bool altForm, bool upper) { + if (precision == 0) precision = 1; + + string mantissa = FormatSignificant(value, precision, out int exp); + + if (exp < -4 || exp >= precision) { + if (!altForm) mantissa = TrimFraction(mantissa); + else if (precision == 1) mantissa += "."; + return mantissa + FormatExponent(exp, upper); + } + + string digits = precision == 1 ? mantissa : mantissa.Remove(1, 1); + string intPart, fraction; + if (exp >= 0) { + intPart = digits.Substring(0, exp + 1); + fraction = digits.Substring(exp + 1); + } else { + intPart = "0"; + fraction = new string('0', -exp - 1) + digits; + } + if (!altForm) fraction = fraction.TrimEnd('0'); + + if (fraction.Length > 0) return intPart + "." + fraction; + if (altForm) return intPart + "."; + return intPart; + + static string TrimFraction(string mantissa) + => mantissa.Length == 1 ? mantissa : mantissa.TrimEnd('0').TrimEnd('.'); + } + + /// + /// Returns the exponent suffix with a sign and at least two digits, e.g. "e+05" or "E-123". + /// + private static string FormatExponent(int exp, bool upper) + => (upper ? "E" : "e") + (exp < 0 ? "-" : "+") + Math.Abs(exp).ToString("D2", CultureInfo.InvariantCulture); + + /// + /// Rounds to significant digits and returns "d.ddd" with the decimal exponent in . + /// + private static string FormatSignificant(double value, int digits, out int exp) { + Debug.Assert(digits > 0); + + if (UseExactDigits(digits - 1)) { + string rounded; + if (value == 0) { + rounded = new string('0', digits); + exp = 0; + } else { + string exact = ExactDigits(value, out int scale); + rounded = RoundDigits(exact, digits); + exp = exact.Length - scale - 1; + if (rounded.Length > digits) { + exp++; + rounded = rounded.Substring(0, digits); + } + } + return digits == 1 ? rounded : rounded.Insert(1, "."); + } + + string res = value.ToString("E" + (digits - 1), CultureInfo.InvariantCulture); + int idx = res.IndexOf('E'); + exp = int.Parse(res.AsSpan(idx + 1), CultureInfo.InvariantCulture); + return res.Substring(0, idx); + } + + /// + /// Rounds to digits after the decimal point. + /// + private static string FormatFixed(double value, int precision) { + if (UseExactDigits(precision)) { + string rounded; + if (value == 0) { + rounded = ""; + } else { + string exact = ExactDigits(value, out int scale); + int keep = exact.Length - scale + precision; + rounded = keep < 0 ? "" : RoundDigits(exact, keep); + } + rounded = rounded.PadLeft(precision + 1, '0'); + return precision == 0 ? rounded : rounded.Insert(rounded.Length - precision, "."); + } + + return value.ToString("F" + precision, CultureInfo.InvariantCulture); + } + + /// + /// Returns the exact decimal expansion of a finite positive value: value == digits * 10**-scale. + /// + private static string ExactDigits(double value, out int scale) { + Debug.Assert(value > 0 && !double.IsInfinity(value)); + + long bits = BitConverter.DoubleToInt64Bits(value); + int exp = (int)((bits >> 52) & 0x7FF); + ulong mantissa = (ulong)bits & 0xFFFFFFFFFFFFFUL; + if (exp == 0) { + exp = 1; + } else { + mantissa |= 1UL << 52; + } + exp -= 1075; + + // m * 2**-k == m * 5**k * 10**-k + if (exp >= 0) { + scale = 0; + return (new BigInteger(mantissa) << exp).ToString(CultureInfo.InvariantCulture); + } + scale = -exp; + return (mantissa * BigInteger.Pow(5, scale)).ToString(CultureInfo.InvariantCulture); + } + + /// + /// Rounds half to even, keeping the first digits. The result has one extra digit if rounding carries. + /// + private static string RoundDigits(string digits, int keep) { + if (keep >= digits.Length) return digits + new string('0', keep - digits.Length); + + bool up = digits[keep] > '5'; + if (digits[keep] == '5') { + for (int i = keep + 1; i < digits.Length; i++) { + if (digits[i] != '0') { + up = true; + break; + } + } + if (!up) up = keep > 0 && (digits[keep - 1] - '0') % 2 == 1; + } + + var res = new StringBuilder(digits, 0, keep, keep + 1); + if (up) { + int i = keep - 1; + while (i >= 0 && res[i] == '9') res[i--] = '0'; + if (i >= 0) res[i]++; + else res.Insert(0, '1'); + } + return res.ToString(); + } + } +} diff --git a/src/core/IronPython/Runtime/StringFormatter.cs b/src/core/IronPython/Runtime/StringFormatter.cs index a2d53725c..b327fab24 100644 --- a/src/core/IronPython/Runtime/StringFormatter.cs +++ b/src/core/IronPython/Runtime/StringFormatter.cs @@ -43,8 +43,6 @@ internal class StringFormatter { // This is a ThreadStatic since so that formatting operations on one thread do not interfere with other threads [ThreadStatic] private static NumberFormatInfo? NumberFormatInfoForThreadLower; - [ThreadStatic] - private static NumberFormatInfo? NumberFormatInfoForThreadUpper; private static NumberFormatInfo nfil { get { @@ -60,20 +58,6 @@ private static NumberFormatInfo nfil { return NumberFormatInfoForThreadLower; } } - private static NumberFormatInfo nfiu { - get { - if (NumberFormatInfoForThreadUpper == null) { - NumberFormatInfo numberFormatInfo = ((CultureInfo)CultureInfo.InvariantCulture.Clone()).NumberFormat; - // The CLI formats as "Infinity", but CPython formats differently - numberFormatInfo.PositiveInfinitySymbol = "INF"; - numberFormatInfo.NegativeInfinitySymbol = "-INF"; - numberFormatInfo.NaNSymbol = "NAN"; - - NumberFormatInfoForThreadUpper = numberFormatInfo; - } - return NumberFormatInfoForThreadUpper; - } - } private NumberFormatInfo _nfi; @@ -325,11 +309,11 @@ private void WriteConversion() { // floating point decimal case 'f': // Same as "e" if exponent is less than -4 or more than precision, "f" otherwise. - case 'g': AppendFloat(_curCh); return; + case 'g': // same as 3 above but uppercase case 'E': case 'F': - case 'G': _nfi = nfiu; AppendFloat(_curCh); _nfi = nfil; return; + case 'G': AppendFloat(_curCh); return; // single character (int or single char str) case 'c': AppendChar(); return; // string (repr() version) @@ -543,82 +527,6 @@ private string ZeroPadInt(object val, bool fPos, int minNumDigits) { return res; } - private static readonly char[] zero = new char[] { '0' }; - - // With .NET Framework "F" formatting is truncated after 15 digits: - private static readonly bool truncatedToString = (1.0 / 3).ToString("F17", CultureInfo.InvariantCulture) == "0.33333333333333300"; - - // Return the new type char to use - private char AdjustForG(char type, double v) { - if (type != 'G' && type != 'g') - return type; - if (double.IsNaN(v) || double.IsInfinity(v)) - return type; - - double absV = Math.Abs(v); - - if (_opts.Precision == 0) { - _opts.Precision = 1; - } - - if ((v != 0.0) && // 0.0 should not be displayed as scientific notation - absV < 1e-4 || // Values less than 0.0001 will need scientific notation - absV >= Math.Pow(10, _opts.Precision)) { // Values bigger than 1e will need scientific notation - - type = (type == 'G') ? 'E' : 'e'; - - // For e/E formatting, precision means the number of digits after the decimal point. - // One digit is displayed before the decimal point. - int fractionDigitsRequired = _opts.Precision - 1; - string expForm = absV.ToString("E" + fractionDigitsRequired, CultureInfo.InvariantCulture); - string mantissa = expForm.Substring(0, expForm.IndexOf('E')).TrimEnd(zero); - if (mantissa.Length == 1) { - _opts.Precision = 0; - } else { - // We do -2 to ignore the digit before the decimal point and the decimal point itself - Debug.Assert(mantissa[1] == '.'); - _opts.Precision = mantissa.Length - 2; - } - } else { - string fixedPointForm; - bool convertType = true; - if (truncatedToString) { - // "0.000ddddd" is allowed when the precision is 5. The 3 leading zeros are not counted - int numberDecimalDigits = _opts.Precision; - if (absV < 1e-3) numberDecimalDigits += 3; - else if (absV < 1e-2) numberDecimalDigits += 2; - else if (absV < 1e-1) numberDecimalDigits += 1; - - fixedPointForm = absV.ToString("F" + numberDecimalDigits, CultureInfo.InvariantCulture).TrimEnd(zero); - if (numberDecimalDigits > 15) { - // System.Double(0.33333333333333331).ToString("F17") == "0.33333333333333300" - string fixedPointFormG = absV.ToString("G" + _opts.Precision, CultureInfo.InvariantCulture); - if (fixedPointFormG.Length > fixedPointForm.Length) { - fixedPointForm = fixedPointFormG; - convertType = false; - } - } - } else { - fixedPointForm = absV.ToString("G" + _opts.Precision, CultureInfo.InvariantCulture); - } - if (convertType) { - type = (type == 'G') ? 'F' : 'f'; - - // For f/F formatting, precision means the number of digits after the decimal point. - var mostSignificantDigit = 1 + (absV == 0 ? 0 : (int)Math.Floor(Math.Log10(absV))); - if (_opts.AltForm) { - _opts.Precision -= mostSignificantDigit; - } else { - var decimalPointIdx = fixedPointForm.IndexOf('.'); - var fractionLength = decimalPointIdx == -1 ? 0 : (fixedPointForm.Length - decimalPointIdx - 1); - _opts.Precision = Math.Min(_opts.Precision - mostSignificantDigit, fractionLength); - } - } - } - - return type; - } - private void AppendFloat(char format) { double val; if (!Converter.TryConvertToDouble(_opts.Value, out val)) @@ -633,10 +541,22 @@ private void AppendFloat(char format) { _opts.Precision = 6; } - format = AdjustForG(format, val); - var fPos = DoubleOps.Sign(val) >= 0 || double.IsNaN(val); - var str = FormatWithPrecision(val, fPos, format); + var str = FloatFormatter.Format(val, format, _opts.Precision, _opts.AltForm); + + // str(System.Single) uses "%.6g" but needs "ddd.0" for whole numbers + if (_trailingZeroAfterWholeFloat && !double.IsNaN(val) && !double.IsInfinity(val) && str.IndexOfAny(['.', 'e', 'E']) == -1) { + str += ".0"; + } + + if (!fPos) { + str = "-" + str; + } else if (_opts.SignChar) { + str = "+" + str; + } else if (_opts.Space) { + str = " " + str; + } + var pad = _opts.FieldWidth - str.Length; // then append @@ -662,81 +582,6 @@ private void AppendFloat(char format) { } } - private static readonly bool needsFixupFloatMinus = $"{-0.1:f0}" == "0"; // fixed in .NET Core 3.1 - - private string FormatWithPrecision(double val, bool fPos, char format) { - string res; - if (double.IsNaN(val) || double.IsInfinity(val)) { - res = val.ToString(_nfi); - if (fPos) { - if (_opts.SignChar) { - res = "+" + res; - } else if (_opts.Space) { - res = " " + res; - } - } - return res; - } else { - if (_opts.Precision < 100 || supportsPrecisionGreaterThan99) { - res = val.ToString($"{format}{_opts.Precision}", _nfi); - } else { - res = val.ToString($"{format}99", _nfi); - res += new string('0', _opts.Precision - 99); - } - res = FixupFloatMinus(val, fPos, res); - if (fPos) { - if (_opts.SignChar) { - res = "+" + res; - } else if (_opts.Space) { - res = " " + res; - } - } - } - - if (format == 'e' || format == 'E') { - res = AdjustExponent(res); - if (_opts.Precision == 0 && _opts.AltForm) { - res = res.Insert(res.IndexOf(format), "."); - } - } else { - if (_opts.Precision == 0 && _opts.AltForm) { - res += "."; - } - } - - // If AdjustForG() sets opts.Precision == 0, it means that no significant digits should be displayed after - // the decimal point. ie. 123.4 should be displayed as "123", not "123.4". However, we might still need a - // decorative ".0". ie. to display "123.0" - if (_trailingZeroAfterWholeFloat && (format == 'f' || format == 'F') && _opts.Precision == 0) - res += ".0"; - - return res; - - // Ensure negative values rounded to 0 show up as -0. - static string FixupFloatMinus(double val, bool fPos, string x) { - if (needsFixupFloatMinus && !fPos && val >= -0.5 && x[0] != '-') { - Debug.Assert(x[0] == '0'); - return "-" + x; - } - return x; - } - - // A strange string formatting bug requires that we use Standard Numeric Format and - // not Custom Numeric Format. Standard Numeric Format produces always a 3 digit exponent - // which needs to be taken care off. - // Example: 9.3126672485384569e+23, precision=16 - // format string "e16" ==> "9.3126672485384569e+023", but we want "e+23", not "e+023" - // format string "0.0000000000000000e+00" ==> "9.3126672485384600e+23", which is a precision error - // so, we have to format with "e16" and strip the zero manually - static string AdjustExponent(string val) { - if (val[val.Length - 3] == '0') { - return val.Remove(val.Length - 3, 1); - } else { - return val; - } - } - } - private static string GetAltFormPrefixForRadix(char format, int radix) { return radix switch { 8 => format + "0", diff --git a/tests/suite/stdlib/test_float.py b/tests/suite/stdlib/test_float.py index e851387b3..43774a7b8 100644 --- a/tests/suite/stdlib/test_float.py +++ b/tests/suite/stdlib/test_float.py @@ -26,7 +26,6 @@ def load_tests(loader, standard_tests, pattern): ] if not is_netcoreapp: failing_tests += [ - test.test_float.FormatTestCase('test_format_testfile'), test.test_float.ReprTestCase('test_short_repr'), ] if sys.version_info >= (3, 6): diff --git a/tests/suite/test_formatting.py b/tests/suite/test_formatting.py index dbabff9c7..b2df47886 100644 --- a/tests/suite/test_formatting.py +++ b/tests/suite/test_formatting.py @@ -3,9 +3,8 @@ # See the LICENSE file in the project root for more information. import math -import os -from iptest import IronPythonTestCase, is_cli, is_netcoreapp, is_netcoreapp21, big, run_test, skipUnlessIronPython +from iptest import IronPythonTestCase, is_cli, big, run_test, skipUnlessIronPython class A: def __str__(self): @@ -19,13 +18,7 @@ def __str__(self): def __repr__(self): return "repr" -format_rounds_to_even = "%.0f" % 2.5 == "2" - class FormattingTest(IronPythonTestCase): - def test_format_rounds_to_even(self): - # https://github.com/IronLanguages/ironpython2/issues/634 - self.assertEqual(format_rounds_to_even, is_netcoreapp and not is_netcoreapp21 or not is_cli) - def test_floats(self): """Formatting of floats""" @@ -56,9 +49,9 @@ def str(x): return "%.12g" % x # More than 12 significant digits near the decimal point, with rounding up - self.assertEqual(str(12345678901.25), "12345678901.2" if format_rounds_to_even else "12345678901.3") + self.assertEqual(str(12345678901.25), "12345678901.2") self.assertEqual(str(123456789012), "123456789012") - self.assertEqual(str(1.234567890125), "1.23456789012" if format_rounds_to_even else "1.23456789013") + self.assertEqual(str(1.234567890125), "1.23456789012") self.assertEqual(str(1.234567890126), "1.23456789013") # Significant digits away from the decimal point @@ -119,7 +112,7 @@ def str(x): return "%.12g" % x (0.0001, "0.0001"), (0.00001, "1e-05"), - (123456.5, "123456" if format_rounds_to_even else "123457"), + (123456.5, "123456"), ] for v in values: @@ -389,35 +382,7 @@ def test_inf_nan(self): self.check_lines(inf_nan_cases) - def test_format_testfile(self): - """the following is borrowed from stdlib""" - - expected_failures = [] - - if not format_rounds_to_even: - expected_failures += """ -%.0f 2.5 -> 2 -%.0f 1e49 -> 9999999999999999464902769475481793196872414789632 -%.0f 9.9999999999999987e+49 -> 99999999999999986860582406952576489172979654066176 -%.0f 1e50 -> 100000000000000007629769841091887003294964970946560 -%.1f 0.25 -> 0.2 -%.2f 0.125 -> 0.12 -%#.0f 2.5 -> 2. -%.0e 2.5 -> 2e+00 -%.0e 4.5 -> 4e+00 -%.0e 6.5 -> 6e+00 -%.0e 8.5 -> 8e+00 -%#.0e 2.5 -> 2.e+00 -%#.0e 4.5 -> 4.e+00 -%#.0e 6.5 -> 6.e+00 -%#.0e 8.5 -> 8.e+00 -""".strip().split("\n") - - import test - with open(os.path.join(test.__path__[0], 'formatfloat_testcases.txt')) as testfile: - self.check_lines(testfile, expected_failures=expected_failures) - - def check_lines(self, lines, expected_failures=None): + def check_lines(self, lines): for i, line in enumerate(lines): if line.startswith('--'): continue @@ -428,9 +393,6 @@ def check_lines(self, lines, expected_failures=None): lhs, rhs = map(str.strip, line.split('->')) fmt, arg = lhs.split() arg = float(arg) - if expected_failures and line in expected_failures: - self.assertNotEqual(fmt % arg, rhs, "line " + str(i+1)) - continue self.assertEqual(fmt % arg, rhs, "line " + str(i+1)) if not math.isnan(arg) and math.copysign(1.0, arg) > 0.0: self.assertEqual(fmt % -arg, '-' + rhs)