Skip to content

Commit 70d03fd

Browse files
Martin-Molineroclaude
authored andcommitted
Make implicit numeric operators follow Python's int and float semantics
Everything went through double, so integral conversions lost precision past 2**53 and returned floats where Python gives ints. Values now behave like the Python number their conversion maps to: exact ints for integral conversions (unbounded, like Python), floats for floating point and decimal ones. - operands arrive as PyObject: ints beyond Int32 failed the binder's object conversion, and numbers implementing __index__ or __float__ (numpy scalars, other CLR types with a conversion) are read too; anything else returns NotImplemented so Python raises its usual TypeError - division by zero raises ZeroDivisionError instead of returning infinity - int() truncates decimals exactly and raises ValueError/OverflowError on NaN/infinity - comparisons between instances of an IComparable type use its own ordering, as they did before the number protocol was added - inherited operators keep winning over the synthesized ones, as in C#; OperatorMethod shares the filtered list, or the inherited slot recursed Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 5eb0583 commit 70d03fd

4 files changed

Lines changed: 476 additions & 32 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ This document follows the conventions laid out in [Keep a CHANGELOG][].
1010
### Added
1111

1212
- Extension methods declared in non-framework assemblies are bound to the type they extend, so they can be called from the instance like in C# (e.g. `order_status.is_open()`). Those marked `[EditorBrowsable(EditorBrowsableState.Never)]`, or declared in a class marked so, keep their static form only
13-
- Types with an implicit conversion to a numeric type support Python arithmetic, comparisons, `float()` and `int()` through that conversion, unless they declare the operator themselves
13+
- Types with an implicit conversion to a numeric type support Python arithmetic, comparisons, `float()` and `int()` through that conversion, behaving like a Python `int` for integral conversions and a `float` otherwise, unless they declare or inherit the operator
1414
- Ability to instantiate new .NET arrays using `Array[T](dim1, dim2, ...)` syntax
1515
- Python operator method will call C# operator method for supported binary and unary operators ([#1324][p1324]).
1616
- Add GetPythonThreadID and Interrupt methods in PythonEngine

‎src/embed_tests/TestImplicitNumericOperators.cs‎

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System;
12
using NUnit.Framework;
23
using Python.Runtime;
34

@@ -42,6 +43,53 @@ public ConvertibleMoney(decimal amount)
4243
public static ConvertibleMoney operator +(ConvertibleMoney a, ConvertibleMoney b) => new(a.Amount + b.Amount);
4344
}
4445

46+
public class ConvertibleCount
47+
{
48+
public long Value { get; }
49+
50+
public ConvertibleCount(long value)
51+
{
52+
Value = value;
53+
}
54+
55+
public static implicit operator long(ConvertibleCount count) => count.Value;
56+
}
57+
58+
public class ConvertibleBase
59+
{
60+
// inherited by ConvertibleDerived, it must keep winning over the synthesized one as in C#
61+
public static string operator +(ConvertibleBase a, ConvertibleBase b) => "base";
62+
}
63+
64+
public class ConvertibleDerived : ConvertibleBase
65+
{
66+
public decimal Amount { get; }
67+
68+
public ConvertibleDerived(decimal amount)
69+
{
70+
Amount = amount;
71+
}
72+
73+
public static implicit operator decimal(ConvertibleDerived derived) => derived.Amount;
74+
}
75+
76+
public class PreciseQuantity : IComparable<PreciseQuantity>
77+
{
78+
public decimal Value { get; }
79+
80+
public PreciseQuantity(decimal value)
81+
{
82+
Value = value;
83+
}
84+
85+
// equal to 1 once converted to double, only the type's own ordering tells them apart
86+
public static PreciseQuantity AboveOne() => new(1.0000000000000000000000000001m);
87+
88+
public int CompareTo(PreciseQuantity other) => Value.CompareTo(other.Value);
89+
90+
public static implicit operator decimal(PreciseQuantity quantity) => quantity.Value;
91+
}
92+
4593
public class NotConvertible
4694
{
4795
}
@@ -79,6 +127,62 @@ def own_operator_wins():
79127
80128
def not_convertible():
81129
return hasattr(NotConvertible(), '__add__'), hasattr(NotConvertible(), '__float__')
130+
131+
count = ConvertibleCount(2)
132+
big = ConvertibleCount(2**53 + 1)
133+
134+
def integer_arithmetic():
135+
results = (count + 1, 1 + count, count + count, count - 5, 10 - count, count * 3, 3 * count, -count, count + True)
136+
return all(type(result) is int for result in results), results
137+
138+
def integer_with_floats():
139+
results = (count + 0.5, 0.5 + count, count * 1.5, count / 2, count / count, 3 / count)
140+
return all(type(result) is float for result in results), results
141+
142+
def large_integers():
143+
exact = 2**53 + 1
144+
return (int(big) == exact, big > 2**53, big - 1 == 2**53, big * big == exact * exact,
145+
-big == -exact, big >= exact, big + 2**70 == exact + 2**70)
146+
147+
def int_conversions():
148+
errors = []
149+
for value in (float('nan'), float('inf')):
150+
try:
151+
int(ConvertibleRatio(value))
152+
except Exception as e:
153+
errors.append(type(e).__name__)
154+
return errors, int(ConvertibleRatio(-1.75)), int(ConvertibleFee(12345678901234567891)) == 12345678901234567891
155+
156+
def division_by_zero():
157+
errors = []
158+
for operation in (lambda: fee / 0, lambda: 1 / ConvertibleFee(0), lambda: count / 0.0, lambda: fee / ConvertibleFee(0)):
159+
try:
160+
operation()
161+
except ZeroDivisionError:
162+
errors.append(True)
163+
return len(errors)
164+
165+
def other_numeric_operands():
166+
margin = ConvertibleRatio(0.5)
167+
return fee + 2**40, count + 2**70, fee + margin, count * margin, count < 2**64
168+
169+
def unsupported_operands():
170+
messages = []
171+
for operation in (lambda: count + 'x', lambda: fee * None, lambda: fee < 'x', lambda: NotConvertible() - fee):
172+
try:
173+
operation()
174+
messages.append(None)
175+
except TypeError as e:
176+
messages.append(str(e))
177+
return messages
178+
179+
def inherited_operator_wins():
180+
derived = ConvertibleDerived(2)
181+
return derived + derived, derived - ConvertibleDerived(1), float(derived)
182+
183+
def own_ordering_wins():
184+
one, above = PreciseQuantity(1), PreciseQuantity.AboveOne()
185+
return above > one, one < above, above >= one, one <= above, above < one, sorted([above, one])[0] is one
82186
";
83187

84188
[OneTimeSetUp]
@@ -150,6 +254,107 @@ public void TheTypesOwnOperatorsKeepWinning()
150254
Assert.AreEqual(1.0, (double)result[2]);
151255
}
152256

257+
[Test]
258+
public void IntegralConversionsBehaveLikeInts()
259+
{
260+
using var _ = Py.GIL();
261+
var result = module.integer_arithmetic();
262+
Assert.IsTrue((bool)result[0], "every result must be an int");
263+
var expected = new long[] { 3, 3, 4, -3, 8, 6, 6, -2, 3 };
264+
for (var i = 0; i < expected.Length; i++)
265+
{
266+
Assert.AreEqual(expected[i], (long)result[1][i], $"index {i}");
267+
}
268+
}
269+
270+
[Test]
271+
public void IntegralConversionsGiveFloatsWherePythonWould()
272+
{
273+
using var _ = Py.GIL();
274+
var result = module.integer_with_floats();
275+
Assert.IsTrue((bool)result[0], "every result must be a float");
276+
var expected = new[] { 2.5, 2.5, 3.0, 1.0, 1.0, 1.5 };
277+
for (var i = 0; i < expected.Length; i++)
278+
{
279+
Assert.AreEqual(expected[i], (double)result[1][i], $"index {i}");
280+
}
281+
}
282+
283+
[Test]
284+
public void LargeIntegralValuesStayExact()
285+
{
286+
using var _ = Py.GIL();
287+
var result = module.large_integers();
288+
for (var i = 0; i < 7; i++)
289+
{
290+
Assert.IsTrue((bool)result[i], $"index {i}");
291+
}
292+
}
293+
294+
[Test]
295+
public void IntConversionFollowsPython()
296+
{
297+
using var _ = Py.GIL();
298+
var result = module.int_conversions();
299+
CollectionAssert.AreEqual(new[] { "ValueError", "OverflowError" }, ((PyObject)result[0]).As<string[]>());
300+
Assert.AreEqual(-1, (int)result[1]);
301+
Assert.IsTrue((bool)result[2], "decimal conversions must truncate exactly");
302+
}
303+
304+
[Test]
305+
public void DivisionByZeroRaisesLikePython()
306+
{
307+
using var _ = Py.GIL();
308+
Assert.AreEqual(4, (int)module.division_by_zero());
309+
}
310+
311+
[Test]
312+
public void LargeIntsAndOtherNumbersAreOperands()
313+
{
314+
using var _ = Py.GIL();
315+
var result = module.other_numeric_operands();
316+
Assert.AreEqual(2.0 + Math.Pow(2, 40), (double)result[0]);
317+
Assert.AreEqual("1180591620717411303426", result[1].ToString());
318+
// another type with an implicit numeric conversion is read through its __float__
319+
Assert.AreEqual(2.5, (double)result[2]);
320+
Assert.AreEqual(1.0, (double)result[3]);
321+
Assert.IsTrue((bool)result[4]);
322+
}
323+
324+
[Test]
325+
public void UnsupportedOperandsRaisePythonsTypeError()
326+
{
327+
using var _ = Py.GIL();
328+
var messages = ((PyObject)module.unsupported_operands()).As<string[]>();
329+
StringAssert.Contains("unsupported operand type(s) for +", messages[0]);
330+
StringAssert.Contains("unsupported operand type(s) for *", messages[1]);
331+
StringAssert.Contains("'<' not supported", messages[2]);
332+
StringAssert.Contains("unsupported operand type(s) for -", messages[3]);
333+
}
334+
335+
[Test]
336+
public void InheritedOperatorsKeepWinning()
337+
{
338+
using var _ = Py.GIL();
339+
var result = module.inherited_operator_wins();
340+
Assert.AreEqual("base", (string)result[0]);
341+
// subtraction is declared nowhere in the hierarchy, so the synthesized one applies
342+
Assert.AreEqual(1.0, (double)result[1]);
343+
Assert.AreEqual(2.0, (double)result[2]);
344+
}
345+
346+
[Test]
347+
public void TheTypesOwnOrderingWinsBetweenItsInstances()
348+
{
349+
using var _ = Py.GIL();
350+
var result = module.own_ordering_wins();
351+
var expected = new[] { true, true, true, true, false, true };
352+
for (var i = 0; i < expected.Length; i++)
353+
{
354+
Assert.AreEqual(expected[i], (bool)result[i], $"index {i}");
355+
}
356+
}
357+
153358
[Test]
154359
public void TypesWithoutAConversionAreUntouched()
155360
{

‎src/runtime/ClassManager.cs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -417,8 +417,8 @@ private static ClassInfo GetClassInfo(Type type, ClassBase impl)
417417
}
418418
else if (ImplicitNumericOps.IsSupported(type))
419419
{
420-
// Types with an implicit numeric conversion get Python's number protocol, keeping the operators they declare themselves
421-
var ops = ImplicitNumericOps.GetMethods(type).Where(op => !local.Contains(op.Name)).ToArray();
420+
// Types with an implicit numeric conversion get Python's number protocol, keeping the operators they declare or inherit
421+
var ops = ImplicitNumericOps.GetMethods(type);
422422
foreach (var op in ops)
423423
{
424424
local.Add(op.Name);

0 commit comments

Comments
 (0)