Skip to content

Commit 339272b

Browse files
jhonabreulMartin-Molineroclaude
authored
Bind extension methods to the extended type so they are callable from the instance (#150)
* Bind extension methods to the extended type so they are callable from the instance C# extension methods were only reachable from Python through their static class, so instance-style calls such as order_status.is_open() failed with AttributeError. Extension methods declared in non-framework assemblies are now indexed during the assembly scan and bound as members of the type they extend, including interface targets on implementing classes. The binder passes the instance as the first argument when an extension method is called through it. Real members with the same name win, and open generic targets are skipped since the binder cannot infer them from the instance. * Update version to 2.0.67 * Bind extension methods only to targets involving a non-framework type Targets made only of framework types are shared by countless classes: extensions on object reached every CLR object, and Accord.Math alone added ~600 methods to each primitive array type. Across Lean's assemblies the index shrinks from 16487 to 653 methods. Such extensions keep their static form. Assemblies without the compiler's assembly-level [Extension] marker are skipped up front, as C# does, and binding a class only walks its interfaces when an interface target was indexed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Keep extension methods marked EditorBrowsable(Never) in their static form [EditorBrowsable(EditorBrowsableState.Never)] hides a member from IntelliSense while keeping it callable. Binding such extension methods to the instance would advertise them anyway, so those methods, and all the extension methods of a class marked so, keep their static form only. It lets a library hide plumbing helpers, like Lean's IAlgorithm extensions, from the instance with a standard attribute. 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 31ccaf3 commit 339272b

9 files changed

Lines changed: 497 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ This document follows the conventions laid out in [Keep a CHANGELOG][].
99

1010
### Added
1111

12+
- 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
1213
- Ability to instantiate new .NET arrays using `Array[T](dim1, dim2, ...)` syntax
1314
- Python operator method will call C# operator method for supported binary and unary operators ([#1324][p1324]).
1415
- Add GetPythonThreadID and Interrupt methods in PythonEngine
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
using System;
2+
using System.Collections;
3+
using System.Collections.Generic;
4+
using System.ComponentModel;
5+
using System.Linq;
6+
using NUnit.Framework;
7+
using Python.Runtime;
8+
9+
namespace Python.EmbeddingTest
10+
{
11+
public enum ExtendedStatus { Open, Closed }
12+
13+
public interface IExtendedShape
14+
{
15+
double Side { get; }
16+
}
17+
18+
public class ExtendedModel
19+
{
20+
public string Name { get; set; } = "model";
21+
22+
public string Existing() => "member";
23+
}
24+
25+
public class DerivedExtendedModel : ExtendedModel
26+
{
27+
}
28+
29+
public class ExtendedSquare : IExtendedShape
30+
{
31+
public double Side => 2;
32+
}
33+
34+
public class DisposableExtendedModel : ExtendedModel, IDisposable
35+
{
36+
public void Dispose()
37+
{
38+
}
39+
}
40+
41+
public static class ExtendedStatusExtensions
42+
{
43+
public static bool IsOpen(this ExtendedStatus status) => status == ExtendedStatus.Open;
44+
45+
public static string Describe(this ExtendedStatus status, string prefix, int times = 1)
46+
=> string.Concat(Enumerable.Repeat(prefix + status, times));
47+
}
48+
49+
public static class ExtendedModelExtensions
50+
{
51+
public static string Describe(this ExtendedModel model, string prefix = "") => prefix + model.Name;
52+
53+
// collides with the real instance method, which must win
54+
public static string Existing(this ExtendedModel model) => "extension";
55+
56+
public static double Area(this IExtendedShape shape) => shape.Side * shape.Side;
57+
58+
public static int CountModels(this List<ExtendedModel> models) => models.Count;
59+
60+
public static string Names(this IEnumerable<ExtendedModel> models) => string.Join(",", models.Select(model => model.Name));
61+
62+
// targets made only of framework types are not bound, they would bloat every class sharing them
63+
public static int Total(this List<int> values) => values.Sum();
64+
65+
public static double SumAll(this double[] values) => values.Sum();
66+
67+
public static string Tag(this object value) => "tag";
68+
69+
public static int CountItems(this IEnumerable values) => values.Cast<object>().Count();
70+
71+
public static bool Release(this IDisposable disposable) => true;
72+
73+
public static T Identity<T>(this T value) => value;
74+
75+
public static string Twice(this string value) => value + value;
76+
77+
// hidden from IntelliSense, so not advertised on the instance either
78+
[EditorBrowsable(EditorBrowsableState.Never)]
79+
public static string Hidden(this ExtendedModel model) => "hidden";
80+
81+
[EditorBrowsable(EditorBrowsableState.Advanced)]
82+
public static string Advanced(this ExtendedModel model) => "advanced";
83+
}
84+
85+
[EditorBrowsable(EditorBrowsableState.Never)]
86+
public static class HiddenExtendedModelExtensions
87+
{
88+
public static string HiddenByClass(this ExtendedModel model) => "hidden by class";
89+
}
90+
91+
public class TestExtensionMethods
92+
{
93+
private static dynamic module;
94+
95+
private const string TestModule = @"
96+
from clr import AddReference
97+
AddReference(""System"")
98+
AddReference(""Python.EmbeddingTest"")
99+
from System import Array
100+
from System.Collections.Generic import List
101+
from System.Text import StringBuilder
102+
from Python.EmbeddingTest import *
103+
104+
def enum_instance():
105+
return ExtendedStatus.Open.IsOpen(), ExtendedStatus.Open.is_open(), ExtendedStatus.Closed.is_open()
106+
107+
def enum_with_args():
108+
return ExtendedStatus.Open.describe('s-', times=2)
109+
110+
def enum_static_forms():
111+
return ExtendedStatusExtensions.is_open(ExtendedStatus.Open), ExtendedStatus.is_open(ExtendedStatus.Closed)
112+
113+
def class_instance():
114+
return ExtendedModel().describe(), ExtendedModel().describe(prefix='p-')
115+
116+
def derived_inherits():
117+
return DerivedExtendedModel().describe('d-')
118+
119+
def interface_target():
120+
return ExtendedSquare().area()
121+
122+
def member_wins():
123+
return ExtendedModel().existing()
124+
125+
def closed_generic():
126+
models = List[ExtendedModel]()
127+
models.Add(ExtendedModel())
128+
models.Add(ExtendedModel())
129+
return models.count_models(), models.names()
130+
131+
def framework_targets():
132+
values = List[int]()
133+
values.Add(1)
134+
values.Add(2)
135+
array = Array[float]([1.5, 2.5])
136+
bound = (hasattr(values, 'total'), hasattr(array, 'sum_all'), hasattr(ExtendedModel(), 'tag'),
137+
hasattr(StringBuilder(), 'tag'), hasattr(values, 'count_items'), hasattr(DisposableExtendedModel(), 'release'))
138+
static = (ExtendedModelExtensions.total(values), ExtendedModelExtensions.sum_all(array),
139+
ExtendedModelExtensions.tag(ExtendedModel()), ExtendedModelExtensions.count_items(values),
140+
ExtendedModelExtensions.release(DisposableExtendedModel()))
141+
return bound, static
142+
143+
def open_generic_not_bound():
144+
return hasattr(ExtendedModel(), 'identity')
145+
146+
def editor_browsable():
147+
model = ExtendedModel()
148+
bound = hasattr(model, 'hidden'), hasattr(model, 'hidden_by_class'), model.advanced()
149+
static = ExtendedModelExtensions.hidden(model), HiddenExtendedModelExtensions.hidden_by_class(model)
150+
return bound, static
151+
152+
def python_native_not_bound():
153+
return hasattr('ab', 'twice'), ExtendedModelExtensions.twice('ab')
154+
";
155+
156+
[OneTimeSetUp]
157+
public void OneTimeSetUp()
158+
{
159+
PythonEngine.Initialize();
160+
using var _ = Py.GIL();
161+
module = PyModule.FromString("extension_methods", TestModule);
162+
}
163+
164+
[OneTimeTearDown]
165+
public void Dispose()
166+
{
167+
PythonEngine.Shutdown();
168+
}
169+
170+
[Test]
171+
public void EnumExtensionIsCallableFromTheInstance()
172+
{
173+
using var _ = Py.GIL();
174+
var result = module.enum_instance();
175+
Assert.IsTrue((bool)result[0]);
176+
Assert.IsTrue((bool)result[1]);
177+
Assert.IsFalse((bool)result[2]);
178+
}
179+
180+
[Test]
181+
public void ExtensionArgumentsFollowTheInstance()
182+
{
183+
using var _ = Py.GIL();
184+
Assert.AreEqual("s-Opens-Open", (string)module.enum_with_args());
185+
}
186+
187+
[Test]
188+
public void StaticFormsKeepWorking()
189+
{
190+
using var _ = Py.GIL();
191+
var result = module.enum_static_forms();
192+
Assert.IsTrue((bool)result[0]);
193+
Assert.IsFalse((bool)result[1]);
194+
}
195+
196+
[Test]
197+
public void ClassExtensionIsCallableFromTheInstance()
198+
{
199+
using var _ = Py.GIL();
200+
var result = module.class_instance();
201+
Assert.AreEqual("model", (string)result[0]);
202+
Assert.AreEqual("p-model", (string)result[1]);
203+
}
204+
205+
[Test]
206+
public void DerivedClassInheritsBaseClassExtensions()
207+
{
208+
using var _ = Py.GIL();
209+
Assert.AreEqual("d-model", (string)module.derived_inherits());
210+
}
211+
212+
[Test]
213+
public void InterfaceExtensionIsBoundToImplementingClass()
214+
{
215+
using var _ = Py.GIL();
216+
Assert.AreEqual(4.0, (double)module.interface_target());
217+
}
218+
219+
[Test]
220+
public void RealMemberWinsOverExtension()
221+
{
222+
using var _ = Py.GIL();
223+
Assert.AreEqual("member", (string)module.member_wins());
224+
}
225+
226+
[Test]
227+
public void ClosedGenericTargetOverAUserTypeIsBound()
228+
{
229+
using var _ = Py.GIL();
230+
var result = module.closed_generic();
231+
Assert.AreEqual(2, (int)result[0]);
232+
Assert.AreEqual("model,model", (string)result[1]);
233+
}
234+
235+
[Test]
236+
public void FrameworkOnlyTargetsKeepTheStaticFormOnly()
237+
{
238+
using var _ = Py.GIL();
239+
var result = module.framework_targets();
240+
var bound = result[0];
241+
for (var i = 0; i < 6; i++)
242+
{
243+
Assert.IsFalse((bool)bound[i], $"index {i}");
244+
}
245+
246+
var staticForms = result[1];
247+
Assert.AreEqual(3, (int)staticForms[0]);
248+
Assert.AreEqual(4.0, (double)staticForms[1]);
249+
Assert.AreEqual("tag", (string)staticForms[2]);
250+
Assert.AreEqual(2, (int)staticForms[3]);
251+
Assert.IsTrue((bool)staticForms[4]);
252+
}
253+
254+
[Test]
255+
public void OpenGenericTargetIsNotBound()
256+
{
257+
using var _ = Py.GIL();
258+
Assert.IsFalse((bool)module.open_generic_not_bound());
259+
}
260+
261+
[Test]
262+
public void EditorBrowsableNeverKeepsTheStaticFormOnly()
263+
{
264+
using var _ = Py.GIL();
265+
var result = module.editor_browsable();
266+
Assert.IsFalse((bool)result[0][0]);
267+
Assert.IsFalse((bool)result[0][1]);
268+
Assert.AreEqual("advanced", (string)result[0][2]);
269+
Assert.AreEqual("hidden", (string)result[1][0]);
270+
Assert.AreEqual("hidden by class", (string)result[1][1]);
271+
}
272+
273+
[Test]
274+
public void PythonNativeTargetKeepsTheStaticForm()
275+
{
276+
using var _ = Py.GIL();
277+
var result = module.python_native_not_bound();
278+
Assert.IsFalse((bool)result[0]);
279+
Assert.AreEqual("abab", (string)result[1]);
280+
}
281+
}
282+
}

‎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.66" GeneratePathProperty="true">
16+
<PackageReference Include="quantconnect.pythonnet" Version="2.0.67" 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.66\lib\net10.0\Python.Runtime.dll" DestinationFolder="$(OutDir)baseline" />
28+
<Copy SourceFiles="$(NuGetPackageRoot)quantconnect.pythonnet\2.0.67\lib\net10.0\Python.Runtime.dll" DestinationFolder="$(OutDir)baseline" />
2929
</Target>
3030

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

‎src/runtime/AssemblyManager.cs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ internal static void Initialize()
6262
// test/usage after the first init cycle. Clear so the scan runs fresh.
6363
assembliesNamesCache.Clear();
6464
assemblies.Clear();
65+
ExtensionMethods.Clear();
6566

6667
AppDomain domain = AppDomain.CurrentDomain;
6768

@@ -356,7 +357,8 @@ internal static void ScanAssembly(Assembly assembly)
356357
// gather a list of all of the namespaces contributed to by
357358
// the assembly.
358359

359-
foreach (Type t in GetTypes(assembly))
360+
var types = GetTypes(assembly);
361+
foreach (Type t in types)
360362
{
361363
string ns = t.Namespace ?? "";
362364
if (!namespaces.ContainsKey(ns))
@@ -383,6 +385,8 @@ internal static void ScanAssembly(Assembly assembly)
383385
GenericUtil.Register(t);
384386
}
385387
}
388+
389+
ExtensionMethods.Register(assembly, types);
386390
}
387391

388392
public static AssemblyName[] ListAssemblies()

‎src/runtime/ClassManager.cs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,17 @@ private static ClassInfo GetClassInfo(Type type, ClassBase impl)
416416
}
417417
}
418418

419+
// 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#
420+
var extensionMethods = ExtensionMethods.GetMethodsToBind(type).Where(em => !originalMemberNames.Contains(em.Name)).ToArray();
421+
if (extensionMethods.Length > 0)
422+
{
423+
foreach (var em in extensionMethods)
424+
{
425+
local.Add(em.Name);
426+
}
427+
info = info.Concat(extensionMethods).ToArray();
428+
}
429+
419430
// Now again to filter w/o losing overloaded member info
420431
for (i = 0; i < info.Length; i++)
421432
{

0 commit comments

Comments
 (0)