diff --git a/src/embed_tests/TestCallbacks.cs b/src/embed_tests/TestCallbacks.cs index 88b84d0c3..3938ae106 100644 --- a/src/embed_tests/TestCallbacks.cs +++ b/src/embed_tests/TestCallbacks.cs @@ -25,7 +25,9 @@ public void TestNoOverloadException() { var error = Assert.Throws(() => callWith42(pyFunc)); Assert.AreEqual("TypeError", error.Type.Name); string expectedArgTypes = "()"; - StringAssert.EndsWith(expectedArgTypes, error.Message); + // The message includes the offending argument types, followed by the + // candidate overload signatures, so assert containment rather than suffix. + StringAssert.Contains(expectedArgTypes, error.Message); error.Traceback.Dispose(); } } diff --git a/src/embed_tests/TestFloatToIntConversion.cs b/src/embed_tests/TestFloatToIntConversion.cs new file mode 100644 index 000000000..86c77d082 --- /dev/null +++ b/src/embed_tests/TestFloatToIntConversion.cs @@ -0,0 +1,179 @@ +using NUnit.Framework; +using Python.Runtime; + +namespace Python.EmbeddingTest +{ + /// + /// Passing a Python float where a .NET integer is expected. + /// + /// A float that holds an integral value (e.g. 5.0) is accepted and converted; + /// a non-integral float (e.g. 5.5) is rejected rather than silently truncated. + /// This must hold regardless of whether the target method/constructor has a + /// single signature or several overloads (the latter reproduces Lean's + /// RangeConsolidator(period), which has two int-first constructor overloads). + /// + public class TestFloatToIntConversion + { + private PyModule _module; + + private const string TestModule = @" +from clr import AddReference +AddReference(""Python.EmbeddingTest"") +from Python.EmbeddingTest import IntTaker, OverloadedIntTaker + +def single_ctor(value): + return IntTaker(value).Value + +def single_method(value): + return IntTaker(0).Echo(value) + +def overloaded_ctor(value): + return OverloadedIntTaker(value).Value + +def overloaded_method(value): + return OverloadedIntTaker(0).Echo(value) + +def single_named(value): + return IntTaker(0).ComputeValue(value) + +def overloaded_named(value): + return OverloadedIntTaker(0).ComputeRange(value) + +def single_params(value): + return IntTaker(0).ComputeScaled(value) +"; + + [OneTimeSetUp] + public void Setup() + { + PythonEngine.Initialize(); + _module = PyModule.FromString("float_to_int_module", TestModule); + } + + [OneTimeTearDown] + public void TearDown() + { + _module.Dispose(); + PythonEngine.Shutdown(); + } + + private int Call(string func, double value) + { + using (Py.GIL()) + using (var arg = value.ToPython()) + { + return _module.InvokeMethod(func, arg).As(); + } + } + + // An integral-valued float is accepted and converted, single or overloaded. + [TestCase("single_ctor")] + [TestCase("single_method")] + [TestCase("overloaded_ctor")] + [TestCase("overloaded_method")] + public void IntegralFloat_IsAccepted(string func) + { + Assert.AreEqual(5, Call(func, 5.0)); + } + + // A non-integral float is rejected (no silent truncation) for every target. + [TestCase("single_ctor")] + [TestCase("single_method")] + [TestCase("overloaded_ctor")] + [TestCase("overloaded_method")] + public void NonIntegralFloat_IsRejected(string func) + { + var ex = Assert.Throws(() => Call(func, 5.5)); + Assert.AreEqual("TypeError", ex.Type.Name); + } + + // When no overload matches, the error should hint the expected signature(s). + [Test] + public void ErrorMessage_SingleOverload_ShowsExpectedSignature() + { + var ex = Assert.Throws(() => Call("single_ctor", 5.5)); + StringAssert.Contains("The expected signature is:", ex.Message); + StringAssert.Contains("Int32 value", ex.Message); + } + + [Test] + public void ErrorMessage_MultipleOverloads_ListsCandidates() + { + var ex = Assert.Throws(() => Call("overloaded_ctor", 5.5)); + StringAssert.Contains("The following overloads are available:", ex.Message); + // The int overload is surfaced, hinting an integer was expected. + StringAssert.Contains("Int32 range", ex.Message); + } + + // The hinted signatures use the snake_case name Python callers use, not the + // original C# name. + [Test] + public void ErrorMessage_SingleOverload_UsesSnakeCaseMethodName() + { + var ex = Assert.Throws(() => Call("single_named", 5.5)); + StringAssert.Contains("compute_value(", ex.Message); + StringAssert.DoesNotContain("ComputeValue", ex.Message); + } + + [Test] + public void ErrorMessage_MultipleOverloads_UseSnakeCaseMethodName() + { + var ex = Assert.Throws(() => Call("overloaded_named", 5.5)); + StringAssert.Contains("compute_range(", ex.Message); + StringAssert.DoesNotContain("ComputeRange", ex.Message); + } + + // The hinted signatures also snake_case the parameter names. + [Test] + public void ErrorMessage_SignatureParameters_AreSnakeCase() + { + var ex = Assert.Throws(() => Call("single_params", 5.5)); + StringAssert.Contains("scale_factor", ex.Message); + StringAssert.DoesNotContain("scaleFactor", ex.Message); + } + } + + public class IntTaker + { + public int Value { get; } + + public IntTaker(int value) + { + Value = value; + } + + public int Echo(int value) => value; + + public int ComputeValue(int value) => value; + + public int ComputeScaled(int scaleFactor) => scaleFactor; + } + + /// + /// Mimics Lean's RangeConsolidator: two overloads that both take an int first + /// parameter, differing only in the (defaulted) later parameters. This forces the + /// binder through its overload-disambiguation path. + /// + public class OverloadedIntTaker + { + public int Value { get; } + + public OverloadedIntTaker(int range, System.Func selector = null) + { + Value = range; + } + + public OverloadedIntTaker(int range, PyObject selector, PyObject volumeSelector = null) + { + Value = range; + } + + public int Echo(int value, System.Func selector = null) => value; + + public int Echo(int value, PyObject selector, PyObject other = null) => value; + + public int ComputeRange(int value, System.Func selector = null) => value; + + public int ComputeRange(int value, PyObject selector, PyObject other = null) => value; + } +} diff --git a/src/perf_tests/Python.PerformanceTests.csproj b/src/perf_tests/Python.PerformanceTests.csproj index 6ff2e9d51..24c4793a1 100644 --- a/src/perf_tests/Python.PerformanceTests.csproj +++ b/src/perf_tests/Python.PerformanceTests.csproj @@ -13,7 +13,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + compile @@ -25,7 +25,7 @@ - + diff --git a/src/runtime/Converter.cs b/src/runtime/Converter.cs index f2c867e43..f0e3ba8d9 100644 --- a/src/runtime/Converter.cs +++ b/src/runtime/Converter.cs @@ -895,6 +895,20 @@ internal static bool ToPrimitive(BorrowedReference value, Type obType, out objec TypeCode tc = Type.GetTypeCode(obType); + // A Python float with a fractional part must not be silently truncated + // into an integer parameter. Integral-valued floats (e.g. 5.0) are still + // accepted. This keeps single- and multi-overload binding consistent: + // MethodBinder only treats integral floats as candidates for integer + // parameters, and this guard enforces the same rule at conversion time. + if (obType.IsInteger() && Runtime.PyFloat_Check(value)) + { + double dbl = Runtime.PyFloat_AsDouble(value); + if (double.IsNaN(dbl) || double.IsInfinity(dbl) || Math.Truncate(dbl) != dbl) + { + goto type_error; + } + } + switch (tc) { case TypeCode.Object: diff --git a/src/runtime/MethodBinder.cs b/src/runtime/MethodBinder.cs index 77f2ac746..e632625de 100644 --- a/src/runtime/MethodBinder.cs +++ b/src/runtime/MethodBinder.cs @@ -679,6 +679,19 @@ internal Binding Bind(BorrowedReference inst, BorrowedReference args, BorrowedRe implicitConversions++; } } + // accepts integral-valued Python floats (e.g. 5.0) for integer + // parameters. Converter.ToManaged rejects non-integral floats + // (e.g. 5.5) so we don't silently truncate. Enums are excluded + // on purpose. + else if (Runtime.PyFloat_Check(op) && underlyingType.IsInteger() && !underlyingType.IsEnum) + { + clrtype = parameter.ParameterType; + typematch = Converter.ToManaged(op, clrtype, out arg, false); + if (typematch) + { + implicitConversions++; + } + } if (!typematch) { // this takes care of implicit conversions @@ -993,17 +1006,27 @@ internal virtual NewReference Invoke(BorrowedReference inst, BorrowedReference a if (!Exceptions.ErrorOccurred()) { var value = new StringBuilder("No method matches given arguments"); + // Use the snake_case name Python callers use, matching the hinted signatures below. if (methodinfo != null && methodinfo.Length > 0) { - value.Append($" for {methodinfo[0].Name}"); + value.Append($" for {SnakeCaseName(methodinfo[0])}"); } else if (list.Count > 0) { - value.Append($" for {list[0].MethodBase.Name}"); + value.Append($" for {SnakeCaseName(list[0].MethodBase)}"); } value.Append(": "); AppendArgumentTypes(to: value, args); + + // List the candidate overloads so the caller can see what was + // expected (e.g. that an int overload exists when a float was + // passed). Applies to every "no match" case, not just numeric ones. + var candidates = methodinfo != null && methodinfo.Length > 0 + ? methodinfo.Cast() + : list?.Select(m => m.MethodBase); + AppendOverloads(value, candidates); + Exceptions.RaiseTypeError(value.ToString()); } @@ -1208,6 +1231,148 @@ protected static void AppendArgumentTypes(StringBuilder to, BorrowedReference ar } to.Append(')'); } + + /// + /// Appends the signatures of the candidate overloads to the given error + /// message, so a failed bind hints the caller at what the method expects. + /// + private static void AppendOverloads(StringBuilder to, IEnumerable methods) + { + if (methods == null) + { + return; + } + + // Building this only runs on the error path; never let it throw and mask + // the original binding failure. + try + { + // Distinct signatures, preserving order. Snake-cased duplicates and + // repeated overloads collapse into a single entry. + var signatures = new List(); + var seen = new HashSet(); + foreach (var method in methods) + { + if (method == null) + { + continue; + } + var signature = FormatSignature(method); + if (seen.Add(signature)) + { + signatures.Add(signature); + } + } + + if (signatures.Count == 0) + { + return; + } + + const int maxShown = 10; + to.Append(signatures.Count == 1 + ? ". The expected signature is:" + : ". The following overloads are available:"); + for (var i = 0; i < signatures.Count && i < maxShown; i++) + { + to.Append("\n ").Append(signatures[i]); + } + if (signatures.Count > maxShown) + { + to.Append($"\n ... and {signatures.Count - maxShown} more"); + } + } + catch + { + // Best-effort hint only. + } + } + + /// + /// Formats a method/constructor as a readable signature using the snake_case + /// name Python callers use, e.g. + /// range_consolidator(Int32 range, Func[IBaseData, Decimal] selector = None). + /// The constructor's special .ctor token is left as-is. + /// + private static string FormatSignature(MethodBase method) + { + var to = new StringBuilder(); + to.Append(SnakeCaseName(method)).Append('('); + var parameters = method.GetParameters(); + for (var i = 0; i < parameters.Length; i++) + { + if (i > 0) + { + to.Append(", "); + } + var parameter = parameters[i]; + if (parameter.IsDefined(typeof(ParamArrayAttribute), false)) + { + to.Append("params "); + } + to.Append(FormatType(parameter.ParameterType)).Append(' ').Append(parameter.Name.ToSnakeCase()); + if (parameter.IsOptional) + { + to.Append(" = ").Append(FormatDefaultValue(parameter.DefaultValue)); + } + } + to.Append(')'); + return to.ToString(); + } + + /// + /// Produces a concise, readable name for a CLR type, unwrapping by-ref and + /// nullable types and rendering generics as Name[Arg1, Arg2]. + /// + private static string FormatType(Type type) + { + if (type.IsByRef) + { + type = type.GetElementType(); + } + + var underlying = Nullable.GetUnderlyingType(type); + if (underlying != null) + { + return FormatType(underlying) + "?"; + } + + if (type.IsGenericType) + { + var name = type.Name; + var tick = name.IndexOf('`'); + if (tick >= 0) + { + name = name.Substring(0, tick); + } + var args = type.GetGenericArguments().Select(FormatType); + return $"{name}[{string.Join(", ", args)}]"; + } + + return type.Name; + } + + /// + /// The snake_case name a Python caller uses for the given method. Constructors + /// keep their special .ctor token (a Python caller invokes the type). + /// + private static string SnakeCaseName(MethodBase method) + { + return method.IsConstructor ? method.Name : method.Name.ToSnakeCase(); + } + + private static string FormatDefaultValue(object value) + { + if (value == null || value is DBNull) + { + return "None"; + } + if (value is string s) + { + return $"\"{s}\""; + } + return value.ToString(); + } } diff --git a/src/runtime/Properties/AssemblyInfo.cs b/src/runtime/Properties/AssemblyInfo.cs index 80dc49025..2a7596eeb 100644 --- a/src/runtime/Properties/AssemblyInfo.cs +++ b/src/runtime/Properties/AssemblyInfo.cs @@ -4,5 +4,5 @@ [assembly: InternalsVisibleTo("Python.EmbeddingTest, PublicKey=00240000048000009400000006020000002400005253413100040000110000005ffd8f49fb44ab0641b3fd8d55e749f716e6dd901032295db641eb98ee46063cbe0d4a1d121ef0bc2af95f8a7438d7a80a3531316e6b75c2dae92fb05a99f03bf7e0c03980e1c3cfb74ba690aca2f3339ef329313bcc5dccced125a4ffdc4531dcef914602cd5878dc5fbb4d4c73ddfbc133f840231343e013762884d6143189")] [assembly: InternalsVisibleTo("Python.Test, PublicKey=00240000048000009400000006020000002400005253413100040000110000005ffd8f49fb44ab0641b3fd8d55e749f716e6dd901032295db641eb98ee46063cbe0d4a1d121ef0bc2af95f8a7438d7a80a3531316e6b75c2dae92fb05a99f03bf7e0c03980e1c3cfb74ba690aca2f3339ef329313bcc5dccced125a4ffdc4531dcef914602cd5878dc5fbb4d4c73ddfbc133f840231343e013762884d6143189")] -[assembly: AssemblyVersion("2.0.56")] -[assembly: AssemblyFileVersion("2.0.56")] +[assembly: AssemblyVersion("2.0.57")] +[assembly: AssemblyFileVersion("2.0.57")] diff --git a/src/runtime/Python.Runtime.csproj b/src/runtime/Python.Runtime.csproj index f06f19706..43988bbf0 100644 --- a/src/runtime/Python.Runtime.csproj +++ b/src/runtime/Python.Runtime.csproj @@ -5,7 +5,7 @@ Python.Runtime Python.Runtime QuantConnect.pythonnet - 2.0.56 + 2.0.57 false LICENSE https://github.com/pythonnet/pythonnet diff --git a/src/runtime/Util/Util.cs b/src/runtime/Util/Util.cs index 157ab386e..8ea7883fe 100644 --- a/src/runtime/Util/Util.cs +++ b/src/runtime/Util/Util.cs @@ -303,5 +303,28 @@ public static bool IsDelegate(this Type type) { return type.IsSubclassOf(typeof(Delegate)); } + + /// + /// Determines whether the specified type is a CLR integer type (signed or unsigned). + /// Enums report an integral too, so callers that want to + /// exclude them must check separately. + /// + public static bool IsInteger(this Type type) + { + switch (Type.GetTypeCode(type)) + { + case TypeCode.Byte: + case TypeCode.SByte: + case TypeCode.Int16: + case TypeCode.UInt16: + case TypeCode.Int32: + case TypeCode.UInt32: + case TypeCode.Int64: + case TypeCode.UInt64: + return true; + default: + return false; + } + } } }