Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions src/embed_tests/TestFloatToIntConversion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,43 @@ def overloaded_named(value):

def single_params(value):
return IntTaker(0).ComputeScaled(value)

class FloatSubclass(float):
# numpy.float64-like: a float subclass
pass

class FloatLike:
# numpy.float32-like: float and (truncating) int conversions, no __index__
def __init__(self, v):
self._v = v
def __float__(self):
return float(self._v)
def __int__(self):
return int(self._v)

class IndexLike:
# numpy.int64-like: a true integer type advertising __index__
def __init__(self, v):
self._v = v
def __index__(self):
return int(self._v)
def __float__(self):
return float(self._v)

def single_ctor_float_subclass(value):
return IntTaker(FloatSubclass(value)).Value

def overloaded_ctor_float_subclass(value):
return OverloadedIntTaker(FloatSubclass(value)).Value

def single_ctor_float_like(value):
return IntTaker(FloatLike(value)).Value

def overloaded_ctor_float_like(value):
return OverloadedIntTaker(FloatLike(value)).Value

def single_ctor_index_like(value):
return IntTaker(IndexLike(value)).Value
";

[OneTimeSetUp]
Expand Down Expand Up @@ -87,6 +124,45 @@ public void NonIntegralFloat_IsRejected(string func)
Assert.AreEqual("TypeError", ex.Type.Name);
}

// Float subclasses (e.g. numpy.float64) follow the plain-float rule.
[TestCase("single_ctor_float_subclass")]
[TestCase("overloaded_ctor_float_subclass")]
public void IntegralFloatSubclass_IsAccepted(string func)
{
Assert.AreEqual(5, Call(func, 5.0));
}

[TestCase("single_ctor_float_subclass")]
[TestCase("overloaded_ctor_float_subclass")]
public void NonIntegralFloatSubclass_IsRejected(string func)
{
var ex = Assert.Throws<PythonException>(() => Call(func, 5.5));
Assert.AreEqual("TypeError", ex.Type.Name);
}

// __float__-only numbers (e.g. numpy.float32) follow the plain-float rule.
[TestCase("single_ctor_float_like")]
[TestCase("overloaded_ctor_float_like")]
public void IntegralFloatLike_IsAccepted(string func)
{
Assert.AreEqual(5, Call(func, 5.0));
}

[TestCase("single_ctor_float_like")]
[TestCase("overloaded_ctor_float_like")]
public void NonIntegralFloatLike_IsRejected(string func)
{
var ex = Assert.Throws<PythonException>(() => Call(func, 5.5));
Assert.AreEqual("TypeError", ex.Type.Name);
}

// __index__ types (e.g. numpy.int64) are integers, not float-like.
[Test]
public void IndexLike_IsAccepted()
{
Assert.AreEqual(5, Call("single_ctor_index_like", 5.0));
}

// When no overload matches, the error should hint the expected signature(s).
[Test]
public void ErrorMessage_SingleOverload_ShowsExpectedSignature()
Expand Down
37 changes: 31 additions & 6 deletions src/runtime/Converter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -907,6 +907,28 @@ internal static int ToInt32(BorrowedReference value)
return checked((int)num);
}

/// <summary>
/// True for Python floats (including subclasses like numpy.float64) and for
/// numbers with __float__ but no __index__ (like numpy.float32); __index__
/// marks a type as losslessly int-convertible, so those are not float-like.
/// </summary>
private static bool IsFloatLike(BorrowedReference value)
{
// fast path for the common case: actual ints
if (Runtime.PyInt_Check(value) || Runtime.PyBool_Check(value))
{
return false;
}

if (Runtime.PyObject_TypeCheck(value, Runtime.PyFloatType))
{
return true;
}

return Runtime.PyObject_HasAttrString(value, "__float__") != 0
&& Runtime.PyObject_HasAttrString(value, "__index__") == 0;
}

/// <summary>
/// Convert a Python value to an instance of a primitive managed type.
/// </summary>
Expand All @@ -918,14 +940,17 @@ 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 (tc.IsInteger() && Runtime.PyFloat_Check(value))
// Reject non-integral float-like values (incl. numpy floats) for integer
// targets; the PyNumber_Long path below would silently truncate them.
if (tc.IsInteger() && IsFloatLike(value))
{
double dbl = Runtime.PyFloat_AsDouble(value);
if (dbl == -1.0 && Exceptions.ErrorOccurred())
{
// don't let a failed __float__ probe leak
Exceptions.Clear();
goto type_error;
}
if (double.IsNaN(dbl) || double.IsInfinity(dbl) || Math.Truncate(dbl) != dbl)
{
goto type_error;
Expand Down
51 changes: 51 additions & 0 deletions tests/test_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,57 @@ def test_int64_conversion():
_ = System.Int64(-9223372036854775809)


def test_numpy_float_to_int_conversion():
"""Non-integral numpy floats are rejected for integer targets, not truncated."""
np = pytest.importorskip("numpy")

ob = ConversionTest()

# integral-valued numpy floats convert
ob.Int32Field = np.float64(20.0)
assert ob.Int32Field == 20

ob.Int32Field = np.float32(21.0)
assert ob.Int32Field == 21

ob.Int64Field = np.float64(22.0)
assert ob.Int64Field == 22

# non-integral numpy floats are rejected, not truncated
with pytest.raises(TypeError):
ConversionTest().Int32Field = np.float64(20.5)

with pytest.raises(TypeError):
ConversionTest().Int32Field = np.float32(20.5)

with pytest.raises(TypeError):
ConversionTest().Int64Field = np.float64(20.5)

# numpy integer scalars keep converting
ob.Int32Field = np.int32(7)
assert ob.Int32Field == 7

ob.Int32Field = np.int64(8)
assert ob.Int32Field == 8

ob.Int64Field = np.int64(9)
assert ob.Int64Field == 9

# plain float behavior is unchanged
ob.Int32Field = 23.0
assert ob.Int32Field == 23

with pytest.raises(TypeError):
ConversionTest().Int32Field = 23.5

# method binding applies the same rule
from Python.Test import MethodTest
assert MethodTest.TestOverloadedNoObject(np.float64(5.0)) == "Got int"

with pytest.raises(TypeError):
MethodTest.TestOverloadedNoObject(np.float64(5.5))


def test_uint16_conversion():
"""Test uint16 conversion."""
assert System.UInt16.MaxValue == 65535
Expand Down
Loading