From ac4086962f079f7f1d29c06617a3458221280d98 Mon Sep 17 00:00:00 2001 From: Benedikt Reinartz Date: Sat, 18 Apr 2026 15:50:25 +0200 Subject: [PATCH] Implement support for DLR get/set --- src/runtime/InteropConfiguration.cs | 1 + .../Mixins/DynamicObjectMixinsProvider.cs | 47 ++++++++ src/runtime/Mixins/dlr.py | 16 +++ src/runtime/PythonEngine.cs | 2 +- src/runtime/Runtime.cs | 2 + src/runtime/Types/ClassBase.cs | 110 ++++++++++++++++++ src/runtime/Types/ClassDerived.cs | 2 +- .../Types/DynamicObjectMemberAccessor.cs | 84 +++++++++++++ src/runtime/Util/ConcurrentLruCache.cs | 103 ++++++++++++++++ src/testing/dlrtest.cs | 31 +++++ tests/test_dynamic.py | 67 +++++++++++ 11 files changed, 463 insertions(+), 2 deletions(-) create mode 100644 src/runtime/Mixins/DynamicObjectMixinsProvider.cs create mode 100644 src/runtime/Mixins/dlr.py create mode 100644 src/runtime/Types/DynamicObjectMemberAccessor.cs create mode 100644 src/runtime/Util/ConcurrentLruCache.cs create mode 100644 src/testing/dlrtest.cs create mode 100644 tests/test_dynamic.py diff --git a/src/runtime/InteropConfiguration.cs b/src/runtime/InteropConfiguration.cs index 781d0d01f..0cd441ebc 100644 --- a/src/runtime/InteropConfiguration.cs +++ b/src/runtime/InteropConfiguration.cs @@ -22,6 +22,7 @@ public static InteropConfiguration MakeDefault() { DefaultBaseTypeProvider.Instance, new CollectionMixinsProvider(new Lazy(() => Py.Import("clr._extras.collections"))), + new DynamicObjectMixinsProvider(new Lazy(() => Py.Import("clr._extras.dlr"))), }, }; } diff --git a/src/runtime/Mixins/DynamicObjectMixinsProvider.cs b/src/runtime/Mixins/DynamicObjectMixinsProvider.cs new file mode 100644 index 000000000..6afa31f7e --- /dev/null +++ b/src/runtime/Mixins/DynamicObjectMixinsProvider.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Dynamic; + +namespace Python.Runtime.Mixins; + +class DynamicObjectMixinsProvider : IPythonBaseTypeProvider, IDisposable +{ + readonly Lazy mixinsModule; + + public DynamicObjectMixinsProvider(Lazy mixinsModule) => + this.mixinsModule = mixinsModule ?? throw new ArgumentNullException(nameof(mixinsModule)); + + public PyObject Mixins => mixinsModule.Value; + + public IEnumerable GetBaseTypes(Type type, IList existingBases) + { + if (type is null) + throw new ArgumentNullException(nameof(type)); + + if (existingBases is null) + throw new ArgumentNullException(nameof(existingBases)); + + if (!typeof(IDynamicMetaObjectProvider).IsAssignableFrom(type)) + return existingBases; + + var newBases = new List(existingBases) + { + new(Mixins.GetAttr("DynamicMetaObjectProviderMixin")) + }; + + if (type.IsInterface && type.BaseType is null) + { + newBases.RemoveAll(@base => PythonReferenceComparer.Instance.Equals(@base, Runtime.PyBaseObjectType)); + } + + return newBases; + } + + public void Dispose() + { + if (this.mixinsModule.IsValueCreated) + { + this.mixinsModule.Value.Dispose(); + } + } +} diff --git a/src/runtime/Mixins/dlr.py b/src/runtime/Mixins/dlr.py new file mode 100644 index 000000000..745e78a69 --- /dev/null +++ b/src/runtime/Mixins/dlr.py @@ -0,0 +1,16 @@ +""" +Implements helpers for Dynamic Language Runtime (DLR) types. +""" + +class DynamicMetaObjectProviderMixin: + def __dir__(self): + names = set(super().__dir__()) + + get_names = getattr(self, "GetDynamicMemberNames", None) + if callable(get_names): + try: + names.update(get_names()) + except Exception: + pass + + return list(sorted(names)) diff --git a/src/runtime/PythonEngine.cs b/src/runtime/PythonEngine.cs index 13855adef..122c4dbfe 100644 --- a/src/runtime/PythonEngine.cs +++ b/src/runtime/PythonEngine.cs @@ -299,7 +299,7 @@ static void LoadSubmodule(BorrowedReference targetModuleDict, string fullName, s static void LoadMixins(BorrowedReference targetModuleDict) { - foreach (string nested in new[] { "collections" }) + foreach (string nested in new[] { "collections", "dlr" }) { LoadSubmodule(targetModuleDict, fullName: "clr._extras." + nested, diff --git a/src/runtime/Runtime.cs b/src/runtime/Runtime.cs index 399608733..865b070e2 100644 --- a/src/runtime/Runtime.cs +++ b/src/runtime/Runtime.cs @@ -151,6 +151,7 @@ internal static void Initialize(bool initSigs = false) GenericUtil.Reset(); ClassManager.Reset(); + ClassBase.Reset(); ClassDerivedObject.Reset(); TypeManager.Initialize(); CLRObject.creationBlocked = false; @@ -280,6 +281,7 @@ internal static void Shutdown() NullGCHandles(ExtensionType.loadedExtensions); ClassManager.RemoveClasses(); + ClassBase.Reset(); TypeManager.RemoveTypes(); _typesInitialized = false; diff --git a/src/runtime/Types/ClassBase.cs b/src/runtime/Types/ClassBase.cs index 3fcb7ca4f..4b5b60d07 100644 --- a/src/runtime/Types/ClassBase.cs +++ b/src/runtime/Types/ClassBase.cs @@ -2,6 +2,7 @@ using System.Collections; using System.Collections.Generic; using System.Diagnostics; +using System.Dynamic; using System.Linq; using System.Reflection; using System.Runtime.InteropServices; @@ -22,6 +23,10 @@ namespace Python.Runtime [Serializable] internal class ClassBase : ManagedType, IDeserializationCallback { + static readonly DynamicObjectMemberAccessor dynamicMemberAccessor = new(); + + internal static void Reset() => dynamicMemberAccessor.Clear(); + [NonSerialized] internal List dotNetMembers = new(); internal Indexer? indexer; @@ -603,6 +608,105 @@ static IEnumerable GetCallImplementations(Type type) => type.GetMethods(BindingFlags.Public | BindingFlags.Instance) .Where(m => m.Name == "__call__"); + static NewReference tp_getattro_dlr(BorrowedReference ob, BorrowedReference key) + { + var attr = Runtime.PyObject_GenericGetAttr(ob, key); + if (!attr.IsNull()) + { + return attr; + } + + // Only run the DLR binder if the error was AttributeError, otherwise preserve the original error + if (Runtime.PyErr_ExceptionMatches(Exceptions.AttributeError) == 0) + { + return default; + } + + if (!Runtime.PyString_Check(key)) + { + return default; + } + + if (GetManagedObject(ob) is not CLRObject co) + { + return default; + } + + // Slot registration already guarantees this type supports DLR + var dynamicObject = (IDynamicMetaObjectProvider)co.inst; + + string? memberName = Runtime.GetManagedString(key); + if (memberName is null) + { + return default; + } + + if (!dynamicMemberAccessor.TryGetMember(dynamicObject, memberName, out object? value)) + { + return default; + } + + // Clear the lingering AttributeError + Runtime.PyErr_Clear(); + + using var pyValue = value.ToPython(); + return pyValue.NewReferenceOrNull(); + } + + static int tp_setattro_dlr(BorrowedReference ob, BorrowedReference key, BorrowedReference val) + { + int result = Runtime.PyObject_GenericSetAttr(ob, key, val); + if (result == 0) + { + return 0; + } + + // Preserve non-attribute errors exactly as they are. + if (Runtime.PyErr_ExceptionMatches(Exceptions.AttributeError) == 0) + { + return -1; + } + + // Deletion fallback is intentionally not handled by DLR binder yet. + if (val == null) + { + return -1; + } + + if (!Runtime.PyString_Check(key)) + { + return -1; + } + + if (GetManagedObject(ob) is not CLRObject co) + { + return -1; + } + + // Slot registration already guarantees this type supports DLR. + var dynamicObject = (IDynamicMetaObjectProvider)co.inst; + + string? memberName = Runtime.GetManagedString(key); + if (memberName is null) + { + return -1; + } + + if (!Converter.ToManaged(val, typeof(object), out object? managedValue, true)) + { + return -1; + } + + if (!dynamicMemberAccessor.TrySetMember(dynamicObject, memberName, managedValue)) + { + return -1; + } + + // Clear the lingering AttributeError + Runtime.PyErr_Clear(); + return 0; + } + public virtual void InitializeSlots(BorrowedReference pyType, SlotsHolder slotsHolder) { if (!this.type.Valid) return; @@ -612,6 +716,12 @@ public virtual void InitializeSlots(BorrowedReference pyType, SlotsHolder slotsH TypeManager.InitializeSlotIfEmpty(pyType, TypeOffset.tp_call, new Interop.BBB_N(tp_call_impl), slotsHolder); } + if (typeof(IDynamicMetaObjectProvider).IsAssignableFrom(this.type.Value)) + { + TypeManager.InitializeSlotIfEmpty(pyType, TypeOffset.tp_getattro, new Interop.BB_N(tp_getattro_dlr), slotsHolder); + TypeManager.InitializeSlotIfEmpty(pyType, TypeOffset.tp_setattro, new Interop.BBB_I32(tp_setattro_dlr), slotsHolder); + } + if (indexer is not null) { if (indexer.CanGet) diff --git a/src/runtime/Types/ClassDerived.cs b/src/runtime/Types/ClassDerived.cs index 592eefd55..8b6739421 100644 --- a/src/runtime/Types/ClassDerived.cs +++ b/src/runtime/Types/ClassDerived.cs @@ -40,7 +40,7 @@ static ClassDerivedObject() moduleBuilders = new Dictionary, ModuleBuilder>(); } - public static void Reset() + public static new void Reset() { assemblyBuilders = new Dictionary(); moduleBuilders = new Dictionary, ModuleBuilder>(); diff --git a/src/runtime/Types/DynamicObjectMemberAccessor.cs b/src/runtime/Types/DynamicObjectMemberAccessor.cs new file mode 100644 index 000000000..e06b64c32 --- /dev/null +++ b/src/runtime/Types/DynamicObjectMemberAccessor.cs @@ -0,0 +1,84 @@ +using System; +using System.Dynamic; +using System.Runtime.CompilerServices; +using Microsoft.CSharp.RuntimeBinder; + +namespace Python.Runtime; + +class DynamicObjectMemberAccessor +{ + const int MaxCacheEntries = 1000; + + readonly ConcurrentLruCache> getters = new(MaxCacheEntries); + readonly ConcurrentLruCache> setters = new(MaxCacheEntries); + + static readonly CSharpArgumentInfo[] getArgumentInfo = + { + CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null), + }; + + static readonly CSharpArgumentInfo[] setArgumentInfo = + { + CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null), + CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null), + }; + + public bool TryGetMember(IDynamicMetaObjectProvider obj, string memberName, out object? value) + { + if (obj is null) + throw new ArgumentNullException(nameof(obj)); + if (memberName is null) + throw new ArgumentNullException(nameof(memberName)); + + var getter = getters.GetOrAdd(new MemberKey(obj.GetType(), memberName), static key => + { + var binder = Binder.GetMember(CSharpBinderFlags.None, key.MemberName, key.Type, getArgumentInfo); + var callSite = CallSite>.Create(binder); + return obj => callSite.Target(callSite, obj); + }); + + try + { + value = getter(obj); + return true; + } + catch (RuntimeBinderException) + { + value = null; + return false; + } + } + + public bool TrySetMember(IDynamicMetaObjectProvider obj, string memberName, object? value) + { + if (obj is null) + throw new ArgumentNullException(nameof(obj)); + if (memberName is null) + throw new ArgumentNullException(nameof(memberName)); + + var setter = setters.GetOrAdd(new MemberKey(obj.GetType(), memberName), static key => + { + var binder = Binder.SetMember(CSharpBinderFlags.None, key.MemberName, key.Type, setArgumentInfo); + var callSite = CallSite>.Create(binder); + return (obj, value) => callSite.Target(callSite, obj, value); + }); + + try + { + setter(obj, value); + return true; + } + catch (RuntimeBinderException) + { + return false; + } + } + + readonly record struct MemberKey(Type Type, string MemberName); + + public void Clear() + { + getters.Clear(); + setters.Clear(); + } +} diff --git a/src/runtime/Util/ConcurrentLruCache.cs b/src/runtime/Util/ConcurrentLruCache.cs new file mode 100644 index 000000000..42acc15d1 --- /dev/null +++ b/src/runtime/Util/ConcurrentLruCache.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace Python.Runtime; + +internal sealed class ConcurrentLruCache where TKey : notnull +{ + readonly ConcurrentDictionary> map = new(); + readonly LinkedList lru = new(); + readonly object gate = new(); + + sealed record CacheItem(TKey Key, TValue Value); + + public ConcurrentLruCache(int capacity) + { + if (capacity <= 0) + throw new ArgumentOutOfRangeException(nameof(capacity), "Capacity must be greater than zero."); + + Capacity = capacity; + } + + public int Capacity { get; private set; } + + public int Count => map.Count; + + public TValue GetOrAdd(TKey key, Func valueFactory) + { + if (valueFactory is null) + throw new ArgumentNullException(nameof(valueFactory)); + + if (TryGetValue(key, out var existing)) + return existing; + + var created = valueFactory(key); + + lock (gate) + { + if (map.TryGetValue(key, out var alreadyAdded)) + { + MoveToFront(alreadyAdded); + return alreadyAdded.Value.Value; + } + + var item = new CacheItem(key, created); + var node = new LinkedListNode(item); + lru.AddFirst(node); + map[key] = node; + EvictOverflow(); + return created; + } + } + + public bool TryGetValue(TKey key, out TValue value) + { + if (map.TryGetValue(key, out var node)) + { + lock (gate) + { + if (map.TryGetValue(key, out node)) + { + MoveToFront(node); + value = node.Value.Value; + return true; + } + } + } + + value = default!; + return false; + } + + public void Clear() + { + lock (gate) + { + lru.Clear(); + map.Clear(); + } + } + + void MoveToFront(LinkedListNode node) + { + if (ReferenceEquals(lru.First, node)) + return; + + lru.Remove(node); + lru.AddFirst(node); + } + + void EvictOverflow() + { + while (map.Count > Capacity) + { + var last = lru.Last; + if (last is null) + return; + + lru.RemoveLast(); + map.TryRemove(last.Value.Key, out _); + } + } +} diff --git a/src/testing/dlrtest.cs b/src/testing/dlrtest.cs new file mode 100644 index 000000000..28814789c --- /dev/null +++ b/src/testing/dlrtest.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Dynamic; + +namespace Python.Test; + +public class DynamicMappingObject : DynamicObject +{ + readonly Dictionary storage = []; + + // Native members for testing that regular CLR access is unaffected. + public string Label = "default"; + public int Multiplier { get; set; } = 1; + public int Multiply(int value) => value * Multiplier; + + // Test helper: bypass normal member binding and write directly to dynamic storage. + public void SetDynamicValue(string name, object value) => storage[name] = value; + + public override bool TryGetMember(GetMemberBinder binder, out object result) + => storage.TryGetValue(binder.Name, out result); + + public override bool TrySetMember(SetMemberBinder binder, object value) + { + storage[binder.Name] = value; + return true; + } + + public override bool TryDeleteMember(DeleteMemberBinder binder) + => storage.Remove(binder.Name); + + public override IEnumerable GetDynamicMemberNames() => storage.Keys; +} diff --git a/tests/test_dynamic.py b/tests/test_dynamic.py new file mode 100644 index 000000000..2ec26da3b --- /dev/null +++ b/tests/test_dynamic.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- + +import pytest +from System.Collections.Generic import Dictionary +from System.Dynamic import ExpandoObject + +from Python.Test import DynamicMappingObject + + +def _mro_names(obj): + return [f"{t.__module__}.{t.__name__}" for t in type(obj).__mro__] + + +@pytest.mark.parametrize( + "obj, expected", + [ + (DynamicMappingObject(), True), + (ExpandoObject(), True), + (Dictionary[str, int](), False), + ], +) +def test_dlr_mixin_presence(obj, expected): + has_mixin = "clr._extras.dlr.DynamicMetaObjectProviderMixin" in _mro_names(obj) + assert has_mixin is expected + + +@pytest.mark.parametrize("obj", [DynamicMappingObject(), ExpandoObject()]) +def test_dynamic_binder(obj): + assert "answer" not in dir(obj) + assert "wrong_answer" not in dir(obj) + + setattr(obj, "answer", 42) + obj.wrong_answer = 54 + + assert obj.answer == 42 + assert obj.wrong_answer == 54 + + assert "answer" in dir(obj) + assert "wrong_answer" in dir(obj) + + +def test_native_members_are_accessible_and_keep_priority(): + obj = DynamicMappingObject() + setattr(obj, "answer", 42) + obj.SetDynamicValue("Multiplier", 999) + + # Native field + assert obj.Label == "default" + obj.Label = "changed" + assert obj.Label == "changed" + + # Native property takes precedence over dynamic fallback + assert obj.Multiplier == 1 + obj.Multiplier = 7 + assert obj.Multiplier == 7 + + # Native method + obj.Multiplier = 3 + assert obj.Multiply(5) == 15 + +def test_dynamic_and_native_members_coexist(): + obj = DynamicMappingObject() + setattr(obj, "answer", 42) + obj.Multiplier = 2 + assert obj.answer == 42 + assert obj.Multiplier == 2 + assert obj.Multiply(10) == 20