From fb5cce6b7398d3dd6a113ea22663a53930f66950 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Thu, 2 Jul 2026 16:25:54 -0400 Subject: [PATCH 1/4] Accept integral-valued floats for integer parameters, reject non-integral Passing a Python float where a .NET integer parameter was expected behaved inconsistently: - Single-overload targets silently truncated any float (e.g. 5.5 -> 5) via Converter.ToManaged. - Overloaded targets rejected every float, including integral-valued ones (e.g. 5.0), with "No method matches given arguments" because the overload disambiguation path did not treat float->int as a valid conversion. This broke calls like RangeConsolidator(period) in Lean when period was a float. Make both paths consistent: an integral-valued float (5.0) is accepted and converted for integer parameters, while a non-integral float (5.5) is rejected with a TypeError instead of being silently truncated. - MethodBinder: treat integral Python floats as implicit-conversion candidates for integer parameters (enums excluded). - Converter.ToPrimitive: reject non-integral Python floats targeting integer types so truncation never happens silently. - Add a shared Type.IsInteger() helper in Util and use it in both places. - Add TestFloatToIntConversion covering single and overloaded ctor/method. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/embed_tests/TestFloatToIntConversion.cs | 117 ++++++++++++++++++++ src/runtime/Converter.cs | 14 +++ src/runtime/MethodBinder.cs | 13 +++ src/runtime/Util/Util.cs | 23 ++++ 4 files changed, 167 insertions(+) create mode 100644 src/embed_tests/TestFloatToIntConversion.cs diff --git a/src/embed_tests/TestFloatToIntConversion.cs b/src/embed_tests/TestFloatToIntConversion.cs new file mode 100644 index 000000000..a35a5800a --- /dev/null +++ b/src/embed_tests/TestFloatToIntConversion.cs @@ -0,0 +1,117 @@ +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) +"; + + [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); + } + } + + public class IntTaker + { + public int Value { get; } + + public IntTaker(int value) + { + Value = value; + } + + public int Echo(int value) => value; + } + + /// + /// 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; + } +} 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..cd47b416b 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 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; + } + } } } From da7cc7f71a897ab8a274303a6ff1b30bdcef620a Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Thu, 2 Jul 2026 16:46:15 -0400 Subject: [PATCH 2/4] Hint candidate overloads in the "No method matches" TypeError When argument binding fails, the raised TypeError only reported the argument types that were passed, giving no clue what the method actually expected. For example RangeConsolidator(5.5) produced: No method matches given arguments for .ctor: () Append the candidate overload signatures to the message so the caller can see what was expected (e.g. that an int overload exists when a float was passed). This applies to every "no match" case, not just numeric conversions. - Single candidate -> ". The expected signature is:" + one signature. - Multiple candidates -> ". The following overloads are available:" + list (distinct, capped at 10 with "... and N more"). Signatures are rendered readably: friendly type names (by-ref/nullable unwrapped, generics as Name[Arg1, Arg2]), params marked, optional parameters shown with their default. The whole hint is best-effort and wrapped in a try/catch so it can never mask the original binding failure. - MethodBinder: add AppendOverloads/FormatSignature/FormatType/FormatDefaultValue and emit the hint from Invoke's no-binding path. - Add message tests to TestFloatToIntConversion (single and multiple overloads). - Update TestCallbacks.TestNoOverloadException: the argument types are no longer at the end of the message, so assert containment instead of suffix. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/embed_tests/TestCallbacks.cs | 4 +- src/embed_tests/TestFloatToIntConversion.cs | 18 +++ src/runtime/MethodBinder.cs | 140 ++++++++++++++++++++ 3 files changed, 161 insertions(+), 1 deletion(-) 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 index a35a5800a..9a0069f6b 100644 --- a/src/embed_tests/TestFloatToIntConversion.cs +++ b/src/embed_tests/TestFloatToIntConversion.cs @@ -77,6 +77,24 @@ 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); + } } public class IntTaker diff --git a/src/runtime/MethodBinder.cs b/src/runtime/MethodBinder.cs index cd47b416b..4d43542b1 100644 --- a/src/runtime/MethodBinder.cs +++ b/src/runtime/MethodBinder.cs @@ -1017,6 +1017,15 @@ internal virtual NewReference Invoke(BorrowedReference inst, BorrowedReference a 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()); } @@ -1221,6 +1230,137 @@ 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, e.g. + /// RangeConsolidator(Int32 range, Func[IBaseData, Decimal] selector = None). + /// + private static string FormatSignature(MethodBase method) + { + var to = new StringBuilder(); + to.Append(method.Name).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); + 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; + } + + private static string FormatDefaultValue(object value) + { + if (value == null || value is DBNull) + { + return "None"; + } + if (value is string s) + { + return $"\"{s}\""; + } + return value.ToString(); + } } From d86a0b6033fb884227ff8c2a96ca35c6bca5dab2 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Thu, 2 Jul 2026 17:53:36 -0400 Subject: [PATCH 3/4] Render overload hint in snake_case (method and parameter names) The "No method matches" hint now uses the snake_case names Python callers actually use, both in the message header and in each candidate signature: No method matches given arguments for compute_scaled: (). The expected signature is: compute_scaled(Int32 scale_factor) - Add SnakeCaseName(MethodBase) and use it for the header and FormatSignature (constructors keep their special .ctor token). - Snake_case parameter names in FormatSignature via Name.ToSnakeCase(). - Add tests: method name and parameter names are snake_cased for single and multiple overloads. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/embed_tests/TestFloatToIntConversion.cs | 44 +++++++++++++++++++++ src/runtime/MethodBinder.cs | 24 ++++++++--- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/embed_tests/TestFloatToIntConversion.cs b/src/embed_tests/TestFloatToIntConversion.cs index 9a0069f6b..86c77d082 100644 --- a/src/embed_tests/TestFloatToIntConversion.cs +++ b/src/embed_tests/TestFloatToIntConversion.cs @@ -32,6 +32,15 @@ def overloaded_ctor(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] @@ -95,6 +104,33 @@ public void ErrorMessage_MultipleOverloads_ListsCandidates() // 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 @@ -107,6 +143,10 @@ public IntTaker(int value) } public int Echo(int value) => value; + + public int ComputeValue(int value) => value; + + public int ComputeScaled(int scaleFactor) => scaleFactor; } /// @@ -131,5 +171,9 @@ public OverloadedIntTaker(int range, PyObject selector, PyObject volumeSelector 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/runtime/MethodBinder.cs b/src/runtime/MethodBinder.cs index 4d43542b1..e632625de 100644 --- a/src/runtime/MethodBinder.cs +++ b/src/runtime/MethodBinder.cs @@ -1006,13 +1006,14 @@ 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(": "); @@ -1288,13 +1289,15 @@ private static void AppendOverloads(StringBuilder to, IEnumerable me } /// - /// Formats a method/constructor as a readable signature, e.g. - /// RangeConsolidator(Int32 range, Func[IBaseData, Decimal] selector = None). + /// 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(method.Name).Append('('); + to.Append(SnakeCaseName(method)).Append('('); var parameters = method.GetParameters(); for (var i = 0; i < parameters.Length; i++) { @@ -1307,7 +1310,7 @@ private static string FormatSignature(MethodBase method) { to.Append("params "); } - to.Append(FormatType(parameter.ParameterType)).Append(' ').Append(parameter.Name); + to.Append(FormatType(parameter.ParameterType)).Append(' ').Append(parameter.Name.ToSnakeCase()); if (parameter.IsOptional) { to.Append(" = ").Append(FormatDefaultValue(parameter.DefaultValue)); @@ -1349,6 +1352,15 @@ private static string FormatType(Type type) 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) From e3640aca0bfbf206156f84ed8ced3bdf62c601eb Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Thu, 2 Jul 2026 17:54:41 -0400 Subject: [PATCH 4/4] Update version to 2.0.57 Bump AssemblyVersion/AssemblyFileVersion and the perf-test baseline reference to 2.0.57 to match the package . Co-Authored-By: Claude Opus 4.8 (1M context) --- src/perf_tests/Python.PerformanceTests.csproj | 4 ++-- src/runtime/Properties/AssemblyInfo.cs | 4 ++-- src/runtime/Python.Runtime.csproj | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) 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/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