Skip to content

Commit 8b60b4e

Browse files
jhonabreulclaude
andauthored
Accept integral floats for int params, hint overloads on bind failure (2.0.57) (#128)
* 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) <noreply@anthropic.com> * 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: (<class 'float'>) 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) <noreply@anthropic.com> * 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: (<class 'float'>). 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) <noreply@anthropic.com> * Update version to 2.0.57 Bump AssemblyVersion/AssemblyFileVersion and the perf-test baseline reference to 2.0.57 to match the package <Version>. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Avoid recomputing TypeCode in integral-float checks Add a TypeCode-based IsInteger overload and reuse the TypeCode already computed by the callers in Converter.ToPrimitive and MethodBinder, instead of fetching it again inside IsInteger(Type). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 93ee21a commit 8b60b4e

8 files changed

Lines changed: 401 additions & 8 deletions

File tree

src/embed_tests/TestCallbacks.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ public void TestNoOverloadException() {
2525
var error = Assert.Throws<PythonException>(() => callWith42(pyFunc));
2626
Assert.AreEqual("TypeError", error.Type.Name);
2727
string expectedArgTypes = "(<class 'list'>)";
28-
StringAssert.EndsWith(expectedArgTypes, error.Message);
28+
// The message includes the offending argument types, followed by the
29+
// candidate overload signatures, so assert containment rather than suffix.
30+
StringAssert.Contains(expectedArgTypes, error.Message);
2931
error.Traceback.Dispose();
3032
}
3133
}
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
using NUnit.Framework;
2+
using Python.Runtime;
3+
4+
namespace Python.EmbeddingTest
5+
{
6+
/// <summary>
7+
/// Passing a Python float where a .NET integer is expected.
8+
///
9+
/// A float that holds an integral value (e.g. 5.0) is accepted and converted;
10+
/// a non-integral float (e.g. 5.5) is rejected rather than silently truncated.
11+
/// This must hold regardless of whether the target method/constructor has a
12+
/// single signature or several overloads (the latter reproduces Lean's
13+
/// RangeConsolidator(period), which has two int-first constructor overloads).
14+
/// </summary>
15+
public class TestFloatToIntConversion
16+
{
17+
private PyModule _module;
18+
19+
private const string TestModule = @"
20+
from clr import AddReference
21+
AddReference(""Python.EmbeddingTest"")
22+
from Python.EmbeddingTest import IntTaker, OverloadedIntTaker
23+
24+
def single_ctor(value):
25+
return IntTaker(value).Value
26+
27+
def single_method(value):
28+
return IntTaker(0).Echo(value)
29+
30+
def overloaded_ctor(value):
31+
return OverloadedIntTaker(value).Value
32+
33+
def overloaded_method(value):
34+
return OverloadedIntTaker(0).Echo(value)
35+
36+
def single_named(value):
37+
return IntTaker(0).ComputeValue(value)
38+
39+
def overloaded_named(value):
40+
return OverloadedIntTaker(0).ComputeRange(value)
41+
42+
def single_params(value):
43+
return IntTaker(0).ComputeScaled(value)
44+
";
45+
46+
[OneTimeSetUp]
47+
public void Setup()
48+
{
49+
PythonEngine.Initialize();
50+
_module = PyModule.FromString("float_to_int_module", TestModule);
51+
}
52+
53+
[OneTimeTearDown]
54+
public void TearDown()
55+
{
56+
_module.Dispose();
57+
PythonEngine.Shutdown();
58+
}
59+
60+
private int Call(string func, double value)
61+
{
62+
using (Py.GIL())
63+
using (var arg = value.ToPython())
64+
{
65+
return _module.InvokeMethod(func, arg).As<int>();
66+
}
67+
}
68+
69+
// An integral-valued float is accepted and converted, single or overloaded.
70+
[TestCase("single_ctor")]
71+
[TestCase("single_method")]
72+
[TestCase("overloaded_ctor")]
73+
[TestCase("overloaded_method")]
74+
public void IntegralFloat_IsAccepted(string func)
75+
{
76+
Assert.AreEqual(5, Call(func, 5.0));
77+
}
78+
79+
// A non-integral float is rejected (no silent truncation) for every target.
80+
[TestCase("single_ctor")]
81+
[TestCase("single_method")]
82+
[TestCase("overloaded_ctor")]
83+
[TestCase("overloaded_method")]
84+
public void NonIntegralFloat_IsRejected(string func)
85+
{
86+
var ex = Assert.Throws<PythonException>(() => Call(func, 5.5));
87+
Assert.AreEqual("TypeError", ex.Type.Name);
88+
}
89+
90+
// When no overload matches, the error should hint the expected signature(s).
91+
[Test]
92+
public void ErrorMessage_SingleOverload_ShowsExpectedSignature()
93+
{
94+
var ex = Assert.Throws<PythonException>(() => Call("single_ctor", 5.5));
95+
StringAssert.Contains("The expected signature is:", ex.Message);
96+
StringAssert.Contains("Int32 value", ex.Message);
97+
}
98+
99+
[Test]
100+
public void ErrorMessage_MultipleOverloads_ListsCandidates()
101+
{
102+
var ex = Assert.Throws<PythonException>(() => Call("overloaded_ctor", 5.5));
103+
StringAssert.Contains("The following overloads are available:", ex.Message);
104+
// The int overload is surfaced, hinting an integer was expected.
105+
StringAssert.Contains("Int32 range", ex.Message);
106+
}
107+
108+
// The hinted signatures use the snake_case name Python callers use, not the
109+
// original C# name.
110+
[Test]
111+
public void ErrorMessage_SingleOverload_UsesSnakeCaseMethodName()
112+
{
113+
var ex = Assert.Throws<PythonException>(() => Call("single_named", 5.5));
114+
StringAssert.Contains("compute_value(", ex.Message);
115+
StringAssert.DoesNotContain("ComputeValue", ex.Message);
116+
}
117+
118+
[Test]
119+
public void ErrorMessage_MultipleOverloads_UseSnakeCaseMethodName()
120+
{
121+
var ex = Assert.Throws<PythonException>(() => Call("overloaded_named", 5.5));
122+
StringAssert.Contains("compute_range(", ex.Message);
123+
StringAssert.DoesNotContain("ComputeRange", ex.Message);
124+
}
125+
126+
// The hinted signatures also snake_case the parameter names.
127+
[Test]
128+
public void ErrorMessage_SignatureParameters_AreSnakeCase()
129+
{
130+
var ex = Assert.Throws<PythonException>(() => Call("single_params", 5.5));
131+
StringAssert.Contains("scale_factor", ex.Message);
132+
StringAssert.DoesNotContain("scaleFactor", ex.Message);
133+
}
134+
}
135+
136+
public class IntTaker
137+
{
138+
public int Value { get; }
139+
140+
public IntTaker(int value)
141+
{
142+
Value = value;
143+
}
144+
145+
public int Echo(int value) => value;
146+
147+
public int ComputeValue(int value) => value;
148+
149+
public int ComputeScaled(int scaleFactor) => scaleFactor;
150+
}
151+
152+
/// <summary>
153+
/// Mimics Lean's RangeConsolidator: two overloads that both take an int first
154+
/// parameter, differing only in the (defaulted) later parameters. This forces the
155+
/// binder through its overload-disambiguation path.
156+
/// </summary>
157+
public class OverloadedIntTaker
158+
{
159+
public int Value { get; }
160+
161+
public OverloadedIntTaker(int range, System.Func<int, int> selector = null)
162+
{
163+
Value = range;
164+
}
165+
166+
public OverloadedIntTaker(int range, PyObject selector, PyObject volumeSelector = null)
167+
{
168+
Value = range;
169+
}
170+
171+
public int Echo(int value, System.Func<int, int> selector = null) => value;
172+
173+
public int Echo(int value, PyObject selector, PyObject other = null) => value;
174+
175+
public int ComputeRange(int value, System.Func<int, int> selector = null) => value;
176+
177+
public int ComputeRange(int value, PyObject selector, PyObject other = null) => value;
178+
}
179+
}

src/perf_tests/Python.PerformanceTests.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
1414
</PackageReference>
1515
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.*" />
16-
<PackageReference Include="quantconnect.pythonnet" Version="2.0.56" GeneratePathProperty="true">
16+
<PackageReference Include="quantconnect.pythonnet" Version="2.0.57" GeneratePathProperty="true">
1717
<IncludeAssets>compile</IncludeAssets>
1818
</PackageReference>
1919
</ItemGroup>
@@ -25,7 +25,7 @@
2525
</Target>
2626

2727
<Target Name="CopyBaseline" AfterTargets="Build">
28-
<Copy SourceFiles="$(NuGetPackageRoot)quantconnect.pythonnet\2.0.56\lib\net10.0\Python.Runtime.dll" DestinationFolder="$(OutDir)baseline" />
28+
<Copy SourceFiles="$(NuGetPackageRoot)quantconnect.pythonnet\2.0.57\lib\net10.0\Python.Runtime.dll" DestinationFolder="$(OutDir)baseline" />
2929
</Target>
3030

3131
<Target Name="CopyNewBuild" AfterTargets="Build">

src/runtime/Converter.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,20 @@ internal static bool ToPrimitive(BorrowedReference value, Type obType, out objec
895895

896896
TypeCode tc = Type.GetTypeCode(obType);
897897

898+
// A Python float with a fractional part must not be silently truncated
899+
// into an integer parameter. Integral-valued floats (e.g. 5.0) are still
900+
// accepted. This keeps single- and multi-overload binding consistent:
901+
// MethodBinder only treats integral floats as candidates for integer
902+
// parameters, and this guard enforces the same rule at conversion time.
903+
if (tc.IsInteger() && Runtime.PyFloat_Check(value))
904+
{
905+
double dbl = Runtime.PyFloat_AsDouble(value);
906+
if (double.IsNaN(dbl) || double.IsInfinity(dbl) || Math.Truncate(dbl) != dbl)
907+
{
908+
goto type_error;
909+
}
910+
}
911+
898912
switch (tc)
899913
{
900914
case TypeCode.Object:

0 commit comments

Comments
 (0)