diff --git a/src/core/IronPython/Runtime/Binding/PythonBinder.cs b/src/core/IronPython/Runtime/Binding/PythonBinder.cs index 5b91b531d..2b83913a4 100644 --- a/src/core/IronPython/Runtime/Binding/PythonBinder.cs +++ b/src/core/IronPython/Runtime/Binding/PythonBinder.cs @@ -760,6 +760,9 @@ internal static void AssertNotExtensionType(Type t) { res[typeof(char)] = new Type[] { typeof(CharOps) }; res[typeof(decimal)] = new Type[] { typeof(DecimalOps) }; res[typeof(float)] = new Type[] { typeof(SingleOps) }; +#if NET6_0_OR_GREATER + res[typeof(Half)] = new Type[] { typeof(HalfOps) }; +#endif res[typeof(DateTime)] = new Type[] { typeof(DateTimeOps) }; return res; diff --git a/src/core/IronPython/Runtime/Converter.cs b/src/core/IronPython/Runtime/Converter.cs index 9dc49999e..5ed8baa49 100644 --- a/src/core/IronPython/Runtime/Converter.cs +++ b/src/core/IronPython/Runtime/Converter.cs @@ -751,9 +751,9 @@ private static bool HasNarrowingConversion(Type fromType, Type toType, Narrowing if (IsPythonFloatingPoint(fromType) && !IsPythonFloatingPoint(toType)) return false; return true; } - if (toType == Int32Type && HasPythonProtocol(fromType, "__int__")) return true; + if (toType == Int32Type && !IsHalf(fromType) && HasPythonProtocol(fromType, "__int__")) return true; if (toType == DoubleType && HasPythonProtocol(fromType, "__float__")) return true; - if (toType == BigIntegerType && HasPythonProtocol(fromType, "__int__")) return true; + if (toType == BigIntegerType && !IsHalf(fromType) && HasPythonProtocol(fromType, "__int__")) return true; } if (allowNarrowing >= PythonNarrowing.IndexOperator) { @@ -929,6 +929,14 @@ internal static bool IsExtensibleNumeric(Type t) { || ExtensibleComplexType.IsAssignableFrom(t); } + private static bool IsHalf(Type t) { +#if NET6_0_OR_GREATER + return t == typeof(Half); +#else + return false; +#endif + } + internal static bool IsPythonNumeric(Type t) => IsNumeric(t) || IsExtensibleNumeric(t); diff --git a/src/core/IronPython/Runtime/Operations/HalfOps.cs b/src/core/IronPython/Runtime/Operations/HalfOps.cs new file mode 100644 index 000000000..3f87b32a9 --- /dev/null +++ b/src/core/IronPython/Runtime/Operations/HalfOps.cs @@ -0,0 +1,125 @@ +// 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 + +#if NET6_0_OR_GREATER + +using System; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text; + +using Microsoft.Scripting.Runtime; + +using IronPython.Runtime.Types; + +namespace IronPython.Runtime.Operations { + public static class HalfOps { + + [StaticExtensionMethod] + public static object __new__(PythonType cls) { + CheckHalfType(cls); + return default(Half); + } + + [StaticExtensionMethod] + public static object __new__(CodeContext/*!*/ context, PythonType cls, object x) { + CheckHalfType(cls); + if (x is Half h) return h; + if (Converter.TryConvertToDouble(x, out double doubleVal)) return (Half)doubleVal; + + return (Half)(double)DoubleOps.NewFloat(context, TypeCache.Double, x); + } + + public static bool __bool__(Half self) => self != Half.Zero; + + public static PythonTuple __getnewargs__(Half self) => PythonTuple.MakeTuple((double)self); + + [PropertyMethod, SpecialName] + public static Half Getreal(Half self) => self; + + [PropertyMethod, SpecialName] + public static Half Getimag(Half self) => default; + + public static Half conjugate(Half self) => self; + + public static string __format__(CodeContext/*!*/ context, Half self, [NotNone] string/*!*/ formatSpec) { + // format(x, '') must be equal to str(x), which is not the same as str((double)x) + if (formatSpec.Length == 0) return __str__(self); + + return DoubleOps.__format__(context, (double)self, formatSpec); + } + + public static string __str__(Half self) => __repr__(self); + + /// + /// Returns the shortest decimal representation that round-trips to the same value. + /// + /// + /// The layout follows NumPy's str(numpy.float16): fixed notation for zero and for + /// magnitudes in [1e-4, 1e3), scientific notation otherwise, e.g. 0.1, 999.5, + /// 1e+03, 6.55e+04, 6e-08, -0.0, inf, nan. + /// + /// Note: repr(Half.MaxValue) == '6.55e+04' and Half('6.55e+04') == Half.MaxValue + /// although float(Half.MaxValue) == 65504. + /// + public static string __repr__(Half self) { + if (Half.IsNaN(self)) return "nan"; + if (Half.IsPositiveInfinity(self)) return "inf"; + if (Half.IsNegativeInfinity(self)) return "-inf"; + + // .NET produces the shortest round-trippable digits, in the form [-]ddd[.ddd][E-dd] + string r = self.ToString("R", CultureInfo.InvariantCulture); + bool isNegative = r[0] == '-'; + if (isNegative) r = r.Substring(1); + + int exp = 0; + int ePos = r.IndexOf('E'); + if (ePos >= 0) { + exp = int.Parse(r.Substring(ePos + 1), NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture); + r = r.Substring(0, ePos); + } + int dotPos = r.IndexOf('.'); + if (dotPos < 0) dotPos = r.Length; + + // value is 0.digits * 10**decPoint + string digits = r.Remove(dotPos, Math.Min(1, r.Length - dotPos)); + int decPoint = dotPos + exp; + int numLeadingZeros = digits.Length - digits.TrimStart('0').Length; + digits = digits.Trim('0'); + decPoint -= numLeadingZeros; + + var sb = new StringBuilder(16); + if (isNegative) sb.Append('-'); + + double abs = Math.Abs((double)self); + if (digits.Length == 0) { + sb.Append("0.0"); + } else if (1e-4 <= abs && abs < 1e3) { + if (decPoint <= 0) { + sb.Append("0.").Append('0', -decPoint).Append(digits); + } else if (decPoint >= digits.Length) { + sb.Append(digits).Append('0', decPoint - digits.Length).Append(".0"); + } else { + sb.Append(digits, 0, decPoint).Append('.').Append(digits, decPoint, digits.Length - decPoint); + } + } else { + sb.Append(digits[0]); + if (digits.Length > 1) sb.Append('.').Append(digits, 1, digits.Length - 1); + int sciExp = decPoint - 1; + sb.Append(sciExp < 0 ? "e-" : "e+").Append(Math.Abs(sciExp).ToString("00", CultureInfo.InvariantCulture)); + } + return sb.ToString(); + } + + private static void CheckHalfType(PythonType cls) { + if (cls != DynamicHelpers.GetPythonTypeFromType(typeof(Half))) { + throw PythonOps.TypeError("Half.__new__: first argument must be Half type."); + } + } + } +} + +#endif diff --git a/tests/suite/test_number.py b/tests/suite/test_number.py index 193a46a17..d046fe259 100644 --- a/tests/suite/test_number.py +++ b/tests/suite/test_number.py @@ -5,7 +5,7 @@ import sys import unittest -from iptest import IronPythonTestCase, is_cli, big, run_test, skipUnlessIronPython +from iptest import IronPythonTestCase, is_cli, is_netcoreapp, is_netstandard, big, run_test, skipUnlessIronPython def get_builtins_dict(): if type(__builtins__) is type(sys): @@ -248,6 +248,204 @@ def __pos__(self): self.assertEqual(int(Single(3.14159)), 3) + @unittest.skipUnless(is_netcoreapp and not is_netstandard, "System.Half requires .NET 5+") + def test_half_new(self): + import clr + from System import Half, Single, Int32 + + self.assertIsInstance(Half(), Half) + self.assertEqual(Half(), clr.Convert(0.0, Half)) + self.assertIsInstance(Half(1.5), Half) + self.assertEqual(Half(1.5), clr.Convert(1.5, Half)) + self.assertEqual(Half(3), clr.Convert(3.0, Half)) + self.assertEqual(Half(big(3)), clr.Convert(3.0, Half)) + self.assertEqual(Half(Int32(3)), clr.Convert(3.0, Half)) + self.assertEqual(Half(Single(2.5)), clr.Convert(2.5, Half)) + self.assertEqual(Half(clr.Convert(2.5, Half)), clr.Convert(2.5, Half)) + self.assertEqual(Half("2.5"), clr.Convert(2.5, Half)) + self.assertEqual(Half(1e10), Half.PositiveInfinity) + self.assertTrue(Half.IsNaN(Half("nan"))) + + self.assertRaises(TypeError, Half, 1.0, 2.0) + self.assertRaises(TypeError, Half, None) + self.assertRaises(ValueError, Half, "abc") + + self.assertRaises(TypeError, Half.__new__, float) + self.assertRaises(TypeError, Half.__new__, float, 1.0) + self.assertRaises(TypeError, Half.__new__, Single, 1.0) + + @unittest.skipUnless(is_netcoreapp and not is_netstandard, "System.Half requires .NET 5+") + def test_half_bool(self): + from System import Half + + # like float: false only when equal to zero, so NaN is true + for h in [Half(0), Half(-0.0)]: + self.assertIs(bool(h), False) + self.assertIs(not h, True) + self.assertEqual(h or 'alt', 'alt') + self.assertIs(h and 'alt', h) + + for h in [Half(1), Half(-1.5), Half.Epsilon, Half.MaxValue, Half.MinValue, + Half.PositiveInfinity, Half.NegativeInfinity, Half.NaN]: + self.assertIs(bool(h), True) + self.assertIs(not h, False) + self.assertIs(h or 'alt', h) + self.assertEqual(h and 'alt', 'alt') + + @unittest.skipUnless(is_netcoreapp and not is_netstandard, "System.Half requires .NET 5+") + def test_half_copy_pickle(self): + import copy, math, pickle + from System import Half + + values = [Half(0), Half(-0.0), Half(1.5), Half(-2.25), Half(0.1), Half.MaxValue, Half.MinValue, Half.Epsilon, + Half.PositiveInfinity, Half.NegativeInfinity] + + self.assertEqual(Half(1.5).__getnewargs__(), (1.5,)) + self.assertIs(type(Half(1.5).__getnewargs__()[0]), float) + + for h in values: + clones = [copy.copy(h), copy.deepcopy(h)] + # like other CLR numeric types, Half cannot be pickled with protocols 0 and 1 + clones += [pickle.loads(pickle.dumps(h, protocol)) for protocol in range(2, pickle.HIGHEST_PROTOCOL + 1)] + for clone in clones: + self.assertIs(type(clone), Half) + self.assertEqual(clone, h) + self.assertEqual(math.copysign(1, clone), math.copysign(1, h)) + + for clone in [copy.copy(Half.NaN), pickle.loads(pickle.dumps(Half.NaN))]: + self.assertIs(type(clone), Half) + self.assertTrue(Half.IsNaN(clone)) + + @unittest.skipUnless(is_netcoreapp and not is_netstandard, "System.Half requires .NET 5+") + def test_half_format(self): + from System import Half + + # Half is formatted like float with the same (exact) value + values = [Half(0.1), Half(-0.0), Half(1.5), Half(-2.25), Half(1/3), Half.MaxValue, Half.MinValue, Half.Epsilon, + Half.PositiveInfinity, Half.NegativeInfinity, Half.NaN] + specs = ['.2f', '10.3f', '<10', '^12.4e', '+g', '.3g', 'G', '.1%', ',.1f', 'e', '010.2f', ' f'] + for h in values: + for spec in specs: + self.assertEqual(format(h, spec), format(float(h), spec)) + + self.assertEqual(format(Half(0.1), '.2f'), '0.10') + self.assertEqual(format(Half(0.1), '.6f'), '0.099976') + self.assertEqual(format(Half(-1.5), '+.3e'), '-1.500e+00') + self.assertEqual(format(Half(65504), ',.1f'), '65,504.0') + self.assertEqual(format(Half(2.5), '*^9.1f'), '***2.5***') + self.assertEqual('{:.1f}'.format(Half(2.375)), '2.4') + self.assertEqual(f'{Half(2.25):>6.2f}', ' 2.25') + + # empty format spec is the same as str() + for h in values: + self.assertEqual(format(h, ''), str(h)) + self.assertEqual(f'{h}', str(h)) + + self.assertRaises(ValueError, format, Half(1.5), 's') + self.assertRaises(ValueError, format, Half(1.5), 'x') + + @unittest.skipUnless(is_netcoreapp and not is_netstandard, "System.Half requires .NET 5+") + def test_half_str_repr(self): + from System import Half, BitConverter, Int16 + from System.Globalization import CultureInfo + from System.Threading import Thread + + # expected values are the same as str(numpy.float16(value)) + cases = [ + (0.0, '0.0'), + (-0.0, '-0.0'), + (1.0, '1.0'), + (-1.5, '-1.5'), + (0.1, '0.1'), + (1/3, '0.3333'), + (3.0, '3.0'), + (100.0, '100.0'), + (999.5, '999.5'), + (1000.0, '1e+03'), + (2048.0, '2.048e+03'), + (2049.0, '2.048e+03'), + (12345.0, '1.2344e+04'), + (65504.0, '6.55e+04'), + (-65504.0, '-6.55e+04'), + (0.0001, '0.0001'), + (0.00011, '0.00011'), + (9.99e-05, '9.99e-05'), + (6.103515625e-05, '6.104e-05'), + (5.960464477539063e-08, '6e-08'), + (1.0009765625, '1.001'), + (float('inf'), 'inf'), + (float('-inf'), '-inf'), + (float('nan'), 'nan'), + ] + culture = Thread.CurrentThread.CurrentCulture + try: + for c in [culture, CultureInfo.InvariantCulture, CultureInfo('de-DE')]: + Thread.CurrentThread.CurrentCulture = c + for value, expected in cases: + self.assertEqual(str(Half(value)), expected) + self.assertEqual(repr(Half(value)), expected) + finally: + Thread.CurrentThread.CurrentCulture = culture + + self.assertEqual(str(-Half.NaN), 'nan') + self.assertEqual(str([Half(0.5), Half(-2)]), '[0.5, -2.0]') + + # every value round-trips through str, and with no more digits than necessary + for bits in range(-0x8000, 0x8000): + h = BitConverter.Int16BitsToHalf(Int16(bits)) + if not Half.IsFinite(h): continue + s = str(h) + self.assertEqual(Half(s), h) + mantissa = s.lstrip('-').split('e')[0] + digits = mantissa.replace('.', '').strip('0') + if len(digits) > 1: + shorter = '{:.{}e}'.format(float(h), len(digits) - 2) + self.assertNotEqual(Half(shorter), h, (s, shorter)) + + @unittest.skipUnless(is_netcoreapp and not is_netstandard, "System.Half requires .NET 5+") + def test_half_math_abs(self): + import math + from System import Half, Math + + # Math has no Abs(Half); the lossless Abs(Double) must be selected, not Abs(Int32) + for value, expected in [(1.5, 1.5), (-2.75, 2.75), (-0.0, 0.0), (65504, 65504.0), (-1e5, math.inf)]: + res = Math.Abs(Half(value)) + self.assertIs(type(res), float) + self.assertEqual(res, expected) + + res = Half.Abs(Half(value)) + self.assertIs(type(res), Half) + self.assertEqual(res, Half(expected)) + + self.assertTrue(math.isnan(Math.Abs(Half('nan')))) + + @unittest.skipUnless(is_netcoreapp and not is_netstandard, "System.Half requires .NET 5+") + def test_half_real_imag_conjugate(self): + import math + from System import Half + + for value in [1.5, -2.25, 0.0, -0.0, 65504, math.inf, -math.inf]: + h = Half(value) + for res in (h.real, h.conjugate()): + self.assertIs(type(res), Half) + self.assertEqual(res, h) + self.assertEqual(math.copysign(1, res), math.copysign(1, h)) + self.assertIs(type(h.imag), Half) + self.assertEqual(h.imag, Half(0)) + self.assertEqual(math.copysign(1, h.imag), 1) + self.assertEqual(complex(h.real, h.imag), complex(float(h))) + + nan = Half('nan') + self.assertTrue(Half.IsNaN(nan.real)) + self.assertTrue(Half.IsNaN(nan.conjugate())) + self.assertEqual(nan.imag, Half(0)) + + # real and imag are read-only + with self.assertRaises(AttributeError): + Half(1).real = Half(2) + with self.assertRaises(AttributeError): + Half(1).imag = Half(2) + #TODO: @skip("interpreted") #Too slow def test_operators(self): def operator_add(a, b) :