Skip to content

Commit ba1b2ea

Browse files
jhonabreulMartin-Molineroclaude
authored
Give types with an implicit numeric conversion Python's number protocol (#151)
* Give types with an implicit numeric conversion Python's number protocol Python operators never consult C# implicit conversions, so a type convertible to decimal or double still raised TypeError on addition, comparison and float(). Such types now get arithmetic, comparison, __float__ and __int__ methods delegating to the conversion, grafted at class binding the same way EnumOps is for enums. Operators the type declares itself are kept. * 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> --------- Co-authored-by: Martin Molinero <martin.molinero1@gmail.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 339272b commit ba1b2ea

7 files changed

Lines changed: 720 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +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, behaving like a Python `int` for integral conversions and a `float` otherwise, unless they declare or inherit the operator
1314
- Ability to instantiate new .NET arrays using `Array[T](dim1, dim2, ...)` syntax
1415
- Python operator method will call C# operator method for supported binary and unary operators ([#1324][p1324]).
1516
- Add GetPythonThreadID and Interrupt methods in PythonEngine
Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
using System;
2+
using NUnit.Framework;
3+
using Python.Runtime;
4+
5+
namespace Python.EmbeddingTest
6+
{
7+
public class ConvertibleFee
8+
{
9+
public decimal Amount { get; }
10+
11+
public ConvertibleFee(decimal amount)
12+
{
13+
Amount = amount;
14+
}
15+
16+
public static implicit operator decimal(ConvertibleFee fee) => fee.Amount;
17+
}
18+
19+
public class ConvertibleRatio
20+
{
21+
public double Value { get; }
22+
23+
public ConvertibleRatio(double value)
24+
{
25+
Value = value;
26+
}
27+
28+
public static implicit operator double(ConvertibleRatio ratio) => ratio.Value;
29+
}
30+
31+
public class ConvertibleMoney
32+
{
33+
public decimal Amount { get; }
34+
35+
public ConvertibleMoney(decimal amount)
36+
{
37+
Amount = amount;
38+
}
39+
40+
public static implicit operator decimal(ConvertibleMoney money) => money.Amount;
41+
42+
// the type's own operator must keep winning over the synthesized one
43+
public static ConvertibleMoney operator +(ConvertibleMoney a, ConvertibleMoney b) => new(a.Amount + b.Amount);
44+
}
45+
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+
93+
public class NotConvertible
94+
{
95+
}
96+
97+
public class TestImplicitNumericOperators
98+
{
99+
private static dynamic module;
100+
101+
private const string TestModule = @"
102+
from clr import AddReference
103+
AddReference(""System"")
104+
AddReference(""Python.EmbeddingTest"")
105+
from Python.EmbeddingTest import *
106+
107+
fee = ConvertibleFee(2)
108+
other = ConvertibleFee(3)
109+
110+
def arithmetic():
111+
return (fee + other, fee + 1, 1 + fee, fee - other, fee - 1, 10 - fee,
112+
fee * other, fee * 2, 2 * fee, other / fee, fee / 2, 10 / fee, -fee)
113+
114+
def aggregates():
115+
return sum([fee, other]), max(fee, other) is other, sorted([other, fee])[0] is fee
116+
117+
def comparisons():
118+
return (fee < other, fee > other, fee <= other, fee >= other,
119+
fee > 0, fee < 2, fee <= 2, fee >= 2, 1 < fee, 2 >= fee)
120+
121+
def conversions():
122+
return float(fee), int(ConvertibleRatio(1.75)), float(ConvertibleRatio(1.75))
123+
124+
def own_operator_wins():
125+
total = ConvertibleMoney(2) + ConvertibleMoney(3)
126+
return type(total).__name__, total.Amount, ConvertibleMoney(2) - ConvertibleMoney(1)
127+
128+
def not_convertible():
129+
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
186+
";
187+
188+
[OneTimeSetUp]
189+
public void OneTimeSetUp()
190+
{
191+
PythonEngine.Initialize();
192+
using var _ = Py.GIL();
193+
module = PyModule.FromString("implicit_numeric_operators", TestModule);
194+
}
195+
196+
[OneTimeTearDown]
197+
public void Dispose()
198+
{
199+
PythonEngine.Shutdown();
200+
}
201+
202+
[Test]
203+
public void ArithmeticDelegatesToTheImplicitConversion()
204+
{
205+
using var _ = Py.GIL();
206+
var result = module.arithmetic();
207+
var expected = new[] { 5.0, 3.0, 3.0, -1.0, 1.0, 8.0, 6.0, 4.0, 4.0, 1.5, 1.0, 5.0, -2.0 };
208+
for (var i = 0; i < expected.Length; i++)
209+
{
210+
Assert.AreEqual(expected[i], (double)result[i], $"index {i}");
211+
}
212+
}
213+
214+
[Test]
215+
public void AggregatesWork()
216+
{
217+
using var _ = Py.GIL();
218+
var result = module.aggregates();
219+
Assert.AreEqual(5.0, (double)result[0]);
220+
Assert.IsTrue((bool)result[1]);
221+
Assert.IsTrue((bool)result[2]);
222+
}
223+
224+
[Test]
225+
public void ComparisonsDelegateToTheImplicitConversion()
226+
{
227+
using var _ = Py.GIL();
228+
var result = module.comparisons();
229+
var expected = new[] { true, false, true, false, true, false, true, true, true, true };
230+
for (var i = 0; i < expected.Length; i++)
231+
{
232+
Assert.AreEqual(expected[i], (bool)result[i], $"index {i}");
233+
}
234+
}
235+
236+
[Test]
237+
public void FloatAndIntConversions()
238+
{
239+
using var _ = Py.GIL();
240+
var result = module.conversions();
241+
Assert.AreEqual(2.0, (double)result[0]);
242+
Assert.AreEqual(1, (int)result[1]);
243+
Assert.AreEqual(1.75, (double)result[2]);
244+
}
245+
246+
[Test]
247+
public void TheTypesOwnOperatorsKeepWinning()
248+
{
249+
using var _ = Py.GIL();
250+
var result = module.own_operator_wins();
251+
Assert.AreEqual("ConvertibleMoney", (string)result[0]);
252+
Assert.AreEqual(5m, (decimal)result[1]);
253+
// subtraction is not declared by the type, so the synthesized one applies
254+
Assert.AreEqual(1.0, (double)result[2]);
255+
}
256+
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+
358+
[Test]
359+
public void TypesWithoutAConversionAreUntouched()
360+
{
361+
using var _ = Py.GIL();
362+
var result = module.not_convertible();
363+
Assert.IsFalse((bool)result[0]);
364+
Assert.IsFalse((bool)result[1]);
365+
}
366+
}
367+
}

‎src/runtime/ClassManager.cs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,16 @@ private static ClassInfo GetClassInfo(Type type, ClassBase impl)
415415
info = info.Concat(opsImpl.GetMethods(OpsHelper.BindingFlags)).ToArray();
416416
}
417417
}
418+
else if (ImplicitNumericOps.IsSupported(type))
419+
{
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);
422+
foreach (var op in ops)
423+
{
424+
local.Add(op.Name);
425+
}
426+
info = info.Concat(ops).ToArray();
427+
}
418428

419429
// Extension methods are bound as members so they can be called from the instance. A real member with the same name wins, as in C#
420430
var extensionMethods = ExtensionMethods.GetMethodsToBind(type).Where(em => !originalMemberNames.Contains(em.Name)).ToArray();

0 commit comments

Comments
 (0)