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..cd44035e4 --- /dev/null +++ b/src/runtime/Mixins/dlr.py @@ -0,0 +1,18 @@ +""" +Implements helpers for Dynamic Language Runtime (DLR) types. +""" + +class DynamicMetaObjectProviderMixin: + def __dir__(self): + names = set(super().__dir__()) + + get_dynamic_member_names = getattr(self, "GetDynamicMemberNames", None) + if callable(get_dynamic_member_names): + try: + for name in get_dynamic_member_names(): + if isinstance(name, str): + names.add(name) + except Exception: + pass + + return list(sorted(names)) diff --git a/src/runtime/PythonEngine.cs b/src/runtime/PythonEngine.cs index fd04d4a3e..264835fff 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/TypeManager.cs b/src/runtime/TypeManager.cs index dbff1fbd4..c02d94a1f 100644 --- a/src/runtime/TypeManager.cs +++ b/src/runtime/TypeManager.cs @@ -1,9 +1,12 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.Dynamic; using System.Linq; using System.Reflection; using System.Runtime.InteropServices; using System.Diagnostics; + using Python.Runtime.Native; using Python.Runtime.StateSerialization; @@ -37,10 +40,190 @@ internal class TypeManager "tp_clear", }; + static readonly DynamicObjectMemberAccessor dynamicMemberAccessor = new(); + + // tp_getattro_dlr_proxy / tp_setattro_dlr_proxy hit HasClrMember on every + // attribute access; cache the reflection result per (Type, name). + static readonly ConcurrentDictionary<(Type, string), bool> _hasClrMemberCache = new(); + + static bool HasClrMember(object instance, string memberName) => + _hasClrMemberCache.GetOrAdd( + (instance.GetType(), memberName), + k => k.Item1.GetMember(k.Item2, BindingFlags.Public | BindingFlags.Instance).Length > 0); + + static bool IsPythonSpecialAttributeName(string memberName) => + memberName.Length > 4 && memberName.StartsWith("__") && memberName.EndsWith("__"); + + static bool TryGetDynamicInstance(BorrowedReference ob, out object instance, out IDynamicMetaObjectProvider dynamicObject) + { + if (ManagedType.GetManagedObject(ob) is CLRObject co && co.inst is IDynamicMetaObjectProvider coDynamic) + { + instance = co.inst; + dynamicObject = coDynamic; + return true; + } + + if (Converter.ToManaged(ob, typeof(IDynamicMetaObjectProvider), out object? managedDynamic, false) + && managedDynamic is IDynamicMetaObjectProvider convertedDynamic) + { + instance = managedDynamic; + dynamicObject = convertedDynamic; + return true; + } + + if (Converter.ToManaged(ob, typeof(object), out object? managedInstance, false) + && managedInstance is IDynamicMetaObjectProvider boxedDynamic) + { + instance = managedInstance; + dynamicObject = boxedDynamic; + return true; + } + + instance = null!; + dynamicObject = null!; + return false; + } + + public static NewReference tp_getattro_dlr_proxy(BorrowedReference ob, BorrowedReference key) + { + var isDynamic = TryGetDynamicInstance(ob, out object instance, out IDynamicMetaObjectProvider dynamicObject); + + // The whole DLR machinery only makes sense with string keys and dynamic objects + if (!isDynamic || !Runtime.PyString_Check(key)) + { + return Runtime.PyObject_GenericGetAttr(ob, key); + } + + string memberName = Runtime.GetManagedString(key)!; + + // Forward requests to GetDynamicMemberNames to the mixin implementation + if (memberName == nameof(DynamicObjectMemberAccessor.GetDynamicMemberNames) + && !HasClrMember(instance, memberName)) + { + using var pyMemberNames = new Func>( + () => dynamicMemberAccessor.GetDynamicMemberNames(dynamicObject) + ).ToPython(); + return pyMemberNames.NewReferenceOrNull(); + } + + // Now, first try to access the Python attribute + var attr = Runtime.PyObject_GenericGetAttr(ob, key); + if (!attr.IsNull()) + return attr; + + // attr is null, so an exception must be set. If that exception is not an AttributeError, + // we return from this function immediately without clearing. All later returns until the + // very end will lead to the AttributeError getting raised. + if (Runtime.PyErr_ExceptionMatches(Exceptions.AttributeError) == 0) + { + return default; + } + + if (HasClrMember(instance, memberName) || IsPythonSpecialAttributeName(memberName)) + { + return default; + } + + bool resolved = false; + object? value = null; + try + { + resolved = dynamicMemberAccessor.TryGetMember(dynamicObject, memberName, out value); + } + catch (Exception e) + { + // Avoid wrapping the CLR exception via Converter.ToPython here: that would trigger + // CLR type initialisation which can re-enter this slot on the same live object, + // causing infinite recursion. A plain RuntimeError with the message is safe. + Runtime.PyErr_Clear(); + Exceptions.SetError(Exceptions.RuntimeError, e.Message); + return default; + } + + if (!resolved) + { + return default; + } + + Runtime.PyErr_Clear(); + + using var pyValue = value.ToPython(); + return pyValue.NewReferenceOrNull(); + } + + public static int tp_setattro_dlr_proxy(BorrowedReference ob, BorrowedReference key, BorrowedReference val) + { + var isDynamic = TryGetDynamicInstance(ob, out object instance, out IDynamicMetaObjectProvider dynamicObject); + + // The whole DLR machinery only makes sense with string keys and dynamic objects + if (!isDynamic || !Runtime.PyString_Check(key)) + { + return Runtime.PyObject_GenericSetAttr(ob, key, val); + } + + string memberName = Runtime.GetManagedString(key)!; + + // For Python-derived types (IPythonDerivedType), the Python descriptor protocol + // (e.g. @property setters) takes priority over DLR member storage. + if (instance is IPythonDerivedType) + { + int pyResult = Runtime.PyObject_GenericSetAttr(ob, key, val); + if (pyResult == 0) + return 0; + + if (Runtime.PyErr_ExceptionMatches(Exceptions.AttributeError) == 0) + return pyResult; + + Runtime.PyErr_Clear(); + // Fall through to DLR fallback below + } + + if (!HasClrMember(instance, memberName) && !IsPythonSpecialAttributeName(memberName)) + { + // Try DLR member storage first + bool handled; + + try + { + if (val.IsNull) + { + handled = dynamicMemberAccessor.TryDeleteMember(dynamicObject, memberName); + } + else + { + object? managedValue = null; + if (val != Runtime.PyNone && !Converter.ToManaged(val, typeof(object), out managedValue, true)) + return -1; + + handled = dynamicMemberAccessor.TrySetMember(dynamicObject, memberName, managedValue); + if (!handled) + { + Exceptions.SetError(Exceptions.AttributeError, $"'{instance.GetType().Name}' object has no attribute '{memberName}'"); + return -1; + } + } + } + catch (Exception e) + { + // Same reasoning as the getter: avoid Converter.ToPython(e) to keep this + // slot re-entry-safe on live dynamic objects. + Exceptions.SetError(Exceptions.RuntimeError, e.Message); + return -1; + } + + if (handled) + return 0; + } + + // Fall back to Python attribute setting + return Runtime.PyObject_GenericSetAttr(ob, key, val); + } + internal static void Initialize() { Debug.Assert(cache.Count == 0, "Cache should be empty", "Some errors may occurred on last shutdown"); + dynamicMemberAccessor.Clear(); using (var plainType = SlotHelper.CreateObjectType()) { subtype_traverse = Util.ReadIntPtr(plainType.Borrow(), TypeOffset.tp_traverse); @@ -64,6 +247,8 @@ internal static void RemoveTypes() } } + dynamicMemberAccessor.Clear(); + foreach (var type in cache.Values) { type.Dispose(); @@ -313,6 +498,13 @@ internal static void InitializeClass(PyType type, ClassBase impl, Type clrType) throw PythonException.ThrowLastAsClrException(); } + if (typeof(IDynamicMetaObjectProvider).IsAssignableFrom(clrType)) + { + InitializeSlot(type, TypeOffset.tp_getattro, new Interop.BB_N(tp_getattro_dlr_proxy), slotsHolder); + InitializeSlot(type, TypeOffset.tp_setattro, new Interop.BBB_I32(tp_setattro_dlr_proxy), slotsHolder); + Runtime.PyType_Modified(type.Reference); + } + var dict = Util.ReadRef(type, TypeOffset.tp_dict); string mn = clrType.Namespace ?? ""; using (var mod = Runtime.PyString_FromString(mn)) diff --git a/src/runtime/Types/ClassBase.cs b/src/runtime/Types/ClassBase.cs index 3fcb7ca4f..3b6744a62 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; diff --git a/src/runtime/Types/ClassDerived.cs b/src/runtime/Types/ClassDerived.cs index 592eefd55..69eba2cc2 100644 --- a/src/runtime/Types/ClassDerived.cs +++ b/src/runtime/Types/ClassDerived.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Dynamic; using System.Diagnostics; using System.Linq; using System.Reflection; @@ -232,6 +233,13 @@ internal static Type CreateDerivedType(string name, continue; } + // Avoid re-entrant DLR binder recursion when Python derives from + // DynamicObject-based types (including overrides in intermediate bases). + if (IsDynamicObjectHookMethod(method)) + { + continue; + } + // skip if this property has already been overridden if ((method.Name.StartsWith("get_") || method.Name.StartsWith("set_")) && pyProperties.Contains(method.Name.Substring(4))) @@ -300,6 +308,35 @@ internal static Type CreateDerivedType(string name, return type; } + static bool IsDynamicObjectHookMethod(MethodInfo method) + { + MethodInfo origin = method.GetBaseDefinition(); + Type? originType = origin.DeclaringType; + if (originType == typeof(DynamicObject)) + { + return origin.Name switch + { + nameof(DynamicObject.TryGetMember) + or nameof(DynamicObject.TrySetMember) + or nameof(DynamicObject.TryDeleteMember) + or nameof(DynamicObject.TryInvokeMember) + or nameof(DynamicObject.TryConvert) + or nameof(DynamicObject.TryGetIndex) + or nameof(DynamicObject.TrySetIndex) + or nameof(DynamicObject.GetDynamicMemberNames) + or nameof(IDynamicMetaObjectProvider.GetMetaObject) => true, + _ => false, + }; + } + + if (originType == typeof(IDynamicMetaObjectProvider)) + { + return origin.Name == nameof(IDynamicMetaObjectProvider.GetMetaObject); + } + + return false; + } + /// /// Add a constructor override that calls the python ctor after calling the base type constructor. /// diff --git a/src/runtime/Types/DynamicObjectMemberAccessor.cs b/src/runtime/Types/DynamicObjectMemberAccessor.cs new file mode 100644 index 000000000..9e1018a15 --- /dev/null +++ b/src/runtime/Types/DynamicObjectMemberAccessor.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using System.Dynamic; +using System.Linq; +using System.Linq.Expressions; +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); + readonly ConcurrentLruCache> deleters = 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 => + { + if (typeof(DynamicObject).IsAssignableFrom(key.Type)) + { + var getBinder = new GetMemberNameBinder(key.MemberName); + return obj => + { + if (((DynamicObject)obj).TryGetMember(getBinder, out object? result)) + { + return result; + } + + throw new RuntimeBinderException($"Could not get member '{key.MemberName}'"); + }; + } + + var binder = Binder.GetMember(CSharpBinderFlags.None, key.MemberName, key.Type, getArgumentInfo); + var callSite = CallSite>.Create(binder); + return obj => callSite.Target(callSite, (IDynamicMetaObjectProvider)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 => + { + if (typeof(DynamicObject).IsAssignableFrom(key.Type)) + { + var setBinder = new SetMemberNameBinder(key.MemberName); + return (obj, value) => + { + if (!((DynamicObject)obj).TrySetMember(setBinder, value)) + { + throw new RuntimeBinderException($"Could not set member '{key.MemberName}'"); + } + }; + } + + var binder = Binder.SetMember(CSharpBinderFlags.None, key.MemberName, key.Type, setArgumentInfo); + var callSite = CallSite>.Create(binder); + return (obj, value) => callSite.Target(callSite, (IDynamicMetaObjectProvider)obj, value); + }); + + try + { + setter(obj, value); + return true; + } + catch (RuntimeBinderException) + { + return false; + } + } + + public bool TryDeleteMember(IDynamicMetaObjectProvider obj, string memberName) + { + if (obj is null) + throw new ArgumentNullException(nameof(obj)); + if (memberName is null) + throw new ArgumentNullException(nameof(memberName)); + + var deleter = deleters.GetOrAdd(new MemberKey(obj.GetType(), memberName), static key => + { + if (typeof(DynamicObject).IsAssignableFrom(key.Type)) + { + var binder = new DeleteMemberNameBinder(key.MemberName); + return obj => ((DynamicObject)obj).TryDeleteMember(binder); + } + + if (typeof(ExpandoObject).IsAssignableFrom(key.Type)) + { + return obj => ((IDictionary)(ExpandoObject)obj).Remove(key.MemberName); + } + + return _ => false; + }); + + try + { + return deleter(obj); + } + catch (RuntimeBinderException) + { + return false; + } + } + + public IReadOnlyCollection GetDynamicMemberNames(IDynamicMetaObjectProvider obj) + { + if (obj is null) + throw new ArgumentNullException(nameof(obj)); + + if (obj is ExpandoObject expandoObject) + { + return ((IDictionary)expandoObject).Keys.ToArray(); + } + + if (obj is DynamicObject dynamicObject) + { + return dynamicObject.GetDynamicMemberNames().ToArray(); + } + + var metaObject = obj.GetMetaObject(Expression.Constant(obj)); + return metaObject.GetDynamicMemberNames().ToArray(); + } + + readonly record struct MemberKey(Type Type, string MemberName); + + sealed class DeleteMemberNameBinder : DeleteMemberBinder + { + public DeleteMemberNameBinder(string name) + : base(name, ignoreCase: false) + { + } + + public override DynamicMetaObject FallbackDeleteMember(DynamicMetaObject target, DynamicMetaObject? errorSuggestion) + => errorSuggestion ?? throw new RuntimeBinderException($"Could not delete member '{Name}'"); + } + + sealed class GetMemberNameBinder : GetMemberBinder + { + public GetMemberNameBinder(string name) + : base(name, ignoreCase: false) + { + } + + public override DynamicMetaObject FallbackGetMember(DynamicMetaObject target, DynamicMetaObject? errorSuggestion) + => errorSuggestion ?? throw new RuntimeBinderException($"Could not get member '{Name}'"); + } + + sealed class SetMemberNameBinder : SetMemberBinder + { + public SetMemberNameBinder(string name) + : base(name, ignoreCase: false) + { + } + + public override DynamicMetaObject FallbackSetMember( + DynamicMetaObject target, + DynamicMetaObject value, + DynamicMetaObject? errorSuggestion) + => errorSuggestion ?? throw new RuntimeBinderException($"Could not set member '{Name}'"); + } + + public void Clear() + { + getters.Clear(); + setters.Clear(); + deleters.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..783a3a133 --- /dev/null +++ b/src/testing/dlrtest.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Dynamic; + +namespace Python.Test; + +/// +/// Base class for dynamic test helpers. Uses lazy storage initialization so that +/// Python-derived subclasses can safely call DynamicObject member hooks before +/// managed field initializers have run. +/// +public class DynamicStorageObject : DynamicObject +{ + Dictionary storage; + + // Python-defined subclasses may reach this type without running managed field + // initializers (see ClassDerivedObject.NewObjectToPython). Via the lazy init + // we can ensure that the access is still safe, even when the constructor has + // not run. + protected Dictionary Storage => storage ??= []; + + public void AddDynamicMember(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; +} + +public class DynamicMappingObject : DynamicStorageObject +{ + // 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; + + // Test helper: retrieve the actual value stored in C# (for verification that None was stored as null) + public object GetDynamicValue(string name) => Storage.TryGetValue(name, out var value) ? value : null; +} + +public class RejectingSetDynamicObject : DynamicStorageObject +{ + public override bool TrySetMember(SetMemberBinder binder, object value) + { + if (!Storage.ContainsKey(binder.Name)) + return false; + + Storage[binder.Name] = value; + return true; + } +} + +public class ThrowingGetDynamicObject : DynamicStorageObject +{ + public override bool TryGetMember(GetMemberBinder binder, out object result) + => throw new InvalidOperationException($"TryGetMember failed for '{binder.Name}'"); +} + +public class ThrowingSetDynamicObject : DynamicStorageObject +{ + public override bool TrySetMember(SetMemberBinder binder, object value) + => throw new InvalidOperationException($"TrySetMember failed for '{binder.Name}'"); +} + +public class RejectingDeleteDynamicObject : DynamicStorageObject +{ + public override bool TryDeleteMember(DeleteMemberBinder binder) + { + if (!Storage.ContainsKey(binder.Name)) + return false; + + return Storage.Remove(binder.Name); + } +} + +public class ThrowingDeleteDynamicObject : DynamicStorageObject +{ + public override bool TryDeleteMember(DeleteMemberBinder binder) + => throw new InvalidOperationException($"TryDeleteMember failed for '{binder.Name}'"); +} diff --git a/tests/test_dynamic.py b/tests/test_dynamic.py new file mode 100644 index 000000000..f093ee19a --- /dev/null +++ b/tests/test_dynamic.py @@ -0,0 +1,218 @@ +# -*- coding: utf-8 -*- + +import pytest +from System.Collections.Generic import Dictionary +from System.Dynamic import ExpandoObject + +from Python.Test import DynamicMappingObject +from Python.Test import RejectingDeleteDynamicObject +from Python.Test import RejectingSetDynamicObject +from Python.Test import ThrowingDeleteDynamicObject +from Python.Test import ThrowingGetDynamicObject +from Python.Test import ThrowingSetDynamicObject + + +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 + + +@pytest.mark.parametrize("obj", [DynamicMappingObject(), ExpandoObject()]) +def test_set_and_get_dynamic_property(obj): + """Test that setting and getting dynamic properties goes through DLR binder.""" + # Get initial value (should be None for non-existent property) + assert not hasattr(obj, "MyProp") + + # Set a dynamic property to a value + obj.MyProp = 42 + assert obj.MyProp == 42 + + # Set to None and verify it stays None through DLR + obj.MyProp = None + assert obj.MyProp is None + + # Set to another value and verify + obj.MyProp = "hello" + assert obj.MyProp == "hello" + + +def test_update_dynamic_value(): + """Dynamic-only members should use DLR get/set/modify/delete end-to-end.""" + obj = DynamicMappingObject() + assert not hasattr(obj, "DynamicOnly") + + # Initial set should create a dynamic member + obj.DynamicOnly = "initial" + assert obj.DynamicOnly == "initial" + + # Modify flows through TrySetMember + obj.DynamicOnly = "updated" + assert obj.DynamicOnly == "updated" + + # Setting None keeps a present member with None value + obj.DynamicOnly = None + assert obj.DynamicOnly is None + + # Delete flows through TryDeleteMember + del obj.DynamicOnly + assert "DynamicOnly" not in dir(obj) + assert not hasattr(obj, "DynamicOnly") + + +def test_dynamic_set_none_updates_managed_store_after_get(): + """Regression: get->set(None)->get must route through DLR and update managed storage.""" + obj = DynamicMappingObject() + obj.SetDynamicValue("MyProp", "initial") + + x = obj.MyProp + assert x == "initial" + + obj.MyProp = None + + y = obj.MyProp + assert y is None + assert obj.GetDynamicValue("MyProp") is None + + +@pytest.mark.parametrize("obj", [DynamicMappingObject(), ExpandoObject()]) +def test_dynamic_member_lifecycle(obj): + """Dynamic members should support set/modify/get/delete via the DLR binder.""" + name = "LifecycleMember" + + assert not hasattr(obj, name) + + setattr(obj, name, 1) + assert getattr(obj, name) == 1 + + setattr(obj, name, 2) + assert getattr(obj, name) == 2 + + delattr(obj, name) + assert not hasattr(obj, name) + + +def test_derive_from_dynamic_class(): + class MyMappingObject(DynamicMappingObject): + __namespace__ = "PythonNetTest" + + def __init__(self): + self._custom = 0 + + @property + def custom_property(self): + return self._custom + + @custom_property.setter + def custom_property(self, i): + self._custom += i + + + obj = MyMappingObject() + with pytest.raises(AttributeError): + x = obj.unknown_property + + assert obj.custom_property == 0 + + obj.custom_property = 5 + assert obj.custom_property == 5 + + obj.custom_property = 5 + assert obj.custom_property == 10 + + obj.other_property = None + assert obj.other_property is None + + +def test_trysetmember_false_raises_attributeerror_instead_of_silent_python_setattr(): + obj = RejectingSetDynamicObject() + + with pytest.raises(AttributeError): + obj.typoed_name = 42 + + assert not hasattr(obj, "typoed_name") + + +def test_trygetmember_exception_is_raised_in_python(): + obj = ThrowingGetDynamicObject() + obj.AddDynamicMember("any_key", 1) + + with pytest.raises(Exception, match="TryGetMember failed for 'any_key'"): + _ = obj.any_key + + +def test_trysetmember_exception_is_raised_in_python(): + obj = ThrowingSetDynamicObject() + + with pytest.raises(Exception, match="TrySetMember failed for 'bad_name'"): + obj.bad_name = 42 + + +def test_trydeletemember_false_raises_attributeerror(): + obj = RejectingDeleteDynamicObject() + obj.AddDynamicMember("existing_name", 42) + + with pytest.raises(AttributeError): + del obj.missing_name + + +def test_trydeletemember_exception_is_raised_in_python(): + obj = ThrowingDeleteDynamicObject() + obj.bad_name = 42 + + with pytest.raises(Exception, match="TryDeleteMember failed for 'bad_name'"): + del obj.bad_name \ No newline at end of file