Skip to content

Commit fb5cce6

Browse files
jhonabreulclaude
andcommitted
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>
1 parent 2ebb253 commit fb5cce6

4 files changed

Lines changed: 167 additions & 0 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
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+
37+
[OneTimeSetUp]
38+
public void Setup()
39+
{
40+
PythonEngine.Initialize();
41+
_module = PyModule.FromString("float_to_int_module", TestModule);
42+
}
43+
44+
[OneTimeTearDown]
45+
public void TearDown()
46+
{
47+
_module.Dispose();
48+
PythonEngine.Shutdown();
49+
}
50+
51+
private int Call(string func, double value)
52+
{
53+
using (Py.GIL())
54+
using (var arg = value.ToPython())
55+
{
56+
return _module.InvokeMethod(func, arg).As<int>();
57+
}
58+
}
59+
60+
// An integral-valued float is accepted and converted, single or overloaded.
61+
[TestCase("single_ctor")]
62+
[TestCase("single_method")]
63+
[TestCase("overloaded_ctor")]
64+
[TestCase("overloaded_method")]
65+
public void IntegralFloat_IsAccepted(string func)
66+
{
67+
Assert.AreEqual(5, Call(func, 5.0));
68+
}
69+
70+
// A non-integral float is rejected (no silent truncation) for every target.
71+
[TestCase("single_ctor")]
72+
[TestCase("single_method")]
73+
[TestCase("overloaded_ctor")]
74+
[TestCase("overloaded_method")]
75+
public void NonIntegralFloat_IsRejected(string func)
76+
{
77+
var ex = Assert.Throws<PythonException>(() => Call(func, 5.5));
78+
Assert.AreEqual("TypeError", ex.Type.Name);
79+
}
80+
}
81+
82+
public class IntTaker
83+
{
84+
public int Value { get; }
85+
86+
public IntTaker(int value)
87+
{
88+
Value = value;
89+
}
90+
91+
public int Echo(int value) => value;
92+
}
93+
94+
/// <summary>
95+
/// Mimics Lean's RangeConsolidator: two overloads that both take an int first
96+
/// parameter, differing only in the (defaulted) later parameters. This forces the
97+
/// binder through its overload-disambiguation path.
98+
/// </summary>
99+
public class OverloadedIntTaker
100+
{
101+
public int Value { get; }
102+
103+
public OverloadedIntTaker(int range, System.Func<int, int> selector = null)
104+
{
105+
Value = range;
106+
}
107+
108+
public OverloadedIntTaker(int range, PyObject selector, PyObject volumeSelector = null)
109+
{
110+
Value = range;
111+
}
112+
113+
public int Echo(int value, System.Func<int, int> selector = null) => value;
114+
115+
public int Echo(int value, PyObject selector, PyObject other = null) => value;
116+
}
117+
}

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 (obType.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:

src/runtime/MethodBinder.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,19 @@ internal Binding Bind(BorrowedReference inst, BorrowedReference args, BorrowedRe
679679
implicitConversions++;
680680
}
681681
}
682+
// accepts integral-valued Python floats (e.g. 5.0) for integer
683+
// parameters. Converter.ToManaged rejects non-integral floats
684+
// (e.g. 5.5) so we don't silently truncate. Enums are excluded
685+
// on purpose.
686+
else if (Runtime.PyFloat_Check(op) && underlyingType.IsInteger() && !underlyingType.IsEnum)
687+
{
688+
clrtype = parameter.ParameterType;
689+
typematch = Converter.ToManaged(op, clrtype, out arg, false);
690+
if (typematch)
691+
{
692+
implicitConversions++;
693+
}
694+
}
682695
if (!typematch)
683696
{
684697
// this takes care of implicit conversions

src/runtime/Util/Util.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,5 +303,28 @@ public static bool IsDelegate(this Type type)
303303
{
304304
return type.IsSubclassOf(typeof(Delegate));
305305
}
306+
307+
/// <summary>
308+
/// Determines whether the specified type is a CLR integer type (signed or unsigned).
309+
/// Enums report an integral <see cref="TypeCode"/> too, so callers that want to
310+
/// exclude them must check <see cref="Type.IsEnum"/> separately.
311+
/// </summary>
312+
public static bool IsInteger(this Type type)
313+
{
314+
switch (Type.GetTypeCode(type))
315+
{
316+
case TypeCode.Byte:
317+
case TypeCode.SByte:
318+
case TypeCode.Int16:
319+
case TypeCode.UInt16:
320+
case TypeCode.Int32:
321+
case TypeCode.UInt32:
322+
case TypeCode.Int64:
323+
case TypeCode.UInt64:
324+
return true;
325+
default:
326+
return false;
327+
}
328+
}
306329
}
307330
}

0 commit comments

Comments
 (0)