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)