Skip to content

Commit 2408c43

Browse files
committed
Catch errors in setting/deleting properties
- Catch exceptions in TrySet/DeleteMember - Convert the exceptions into Python exceptions - Add tests for the remaining cases - Add a note on why the field has to be lazily initialized (general issue with derived classes)
1 parent 69ba861 commit 2408c43

3 files changed

Lines changed: 115 additions & 19 deletions

File tree

src/runtime/TypeManager.cs

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -169,19 +169,32 @@ public static int tp_setattro_dlr_proxy(BorrowedReference ob, BorrowedReference
169169
if (!HasClrMember(instance, memberName) && !IsPythonSpecialAttributeName(memberName))
170170
{
171171
// Try DLR member storage first
172-
bool handled = false;
172+
bool handled;
173173

174-
if (val.IsNull)
174+
try
175175
{
176-
handled = dynamicMemberAccessor.TryDeleteMember(dynamicObject, memberName);
176+
if (val.IsNull)
177+
{
178+
handled = dynamicMemberAccessor.TryDeleteMember(dynamicObject, memberName);
179+
}
180+
else
181+
{
182+
object? managedValue = null;
183+
if (val != Runtime.PyNone && !Converter.ToManaged(val, typeof(object), out managedValue, true))
184+
return -1;
185+
186+
handled = dynamicMemberAccessor.TrySetMember(dynamicObject, memberName, managedValue);
187+
if (!handled)
188+
{
189+
Exceptions.SetError(Exceptions.AttributeError, $"'{instance.GetType().Name}' object has no attribute '{memberName}'");
190+
return -1;
191+
}
192+
}
177193
}
178-
else
194+
catch (Exception e)
179195
{
180-
object? managedValue = null;
181-
if (val != Runtime.PyNone && !Converter.ToManaged(val, typeof(object), out managedValue, true))
182-
return -1;
183-
184-
handled = dynamicMemberAccessor.TrySetMember(dynamicObject, memberName, managedValue);
196+
Exceptions.SetError(e);
197+
return -1;
185198
}
186199

187200
if (handled)

src/testing/dlrtest.cs

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,40 @@
44

55
namespace Python.Test;
66

7-
public class DynamicMappingObject : DynamicObject
7+
/// <summary>
8+
/// Base class for dynamic test helpers. Uses lazy storage initialization so that
9+
/// Python-derived subclasses can safely call DynamicObject member hooks before
10+
/// managed field initializers have run.
11+
/// </summary>
12+
public class DynamicStorageObject : DynamicObject
813
{
914
Dictionary<string, object> storage;
1015

11-
Dictionary<string, object> Storage => storage ??= [];
16+
// Python-defined subclasses may reach this type without running managed field
17+
// initializers (see ClassDerivedObject.NewObjectToPython). Via the lazy init
18+
// we can ensure that the access is still safe, even when the constructor has
19+
// not run.
20+
protected Dictionary<string, object> Storage => storage ??= [];
1221

22+
public void AddDynamicMember(string name, object value) => Storage[name] = value;
23+
24+
public override bool TryGetMember(GetMemberBinder binder, out object result)
25+
=> Storage.TryGetValue(binder.Name, out result);
26+
27+
public override bool TrySetMember(SetMemberBinder binder, object value)
28+
{
29+
Storage[binder.Name] = value;
30+
return true;
31+
}
32+
33+
public override bool TryDeleteMember(DeleteMemberBinder binder)
34+
=> Storage.Remove(binder.Name);
35+
36+
public override IEnumerable<string> GetDynamicMemberNames() => Storage.Keys;
37+
}
38+
39+
public class DynamicMappingObject : DynamicStorageObject
40+
{
1341
// Native members for testing that regular CLR access is unaffected.
1442
public string Label = "default";
1543
public int Multiplier { get; set; } = 1;
@@ -20,20 +48,39 @@ public class DynamicMappingObject : DynamicObject
2048

2149
// Test helper: retrieve the actual value stored in C# (for verification that None was stored as null)
2250
public object GetDynamicValue(string name) => Storage.TryGetValue(name, out var value) ? value : null;
51+
}
2352

24-
25-
26-
public override bool TryGetMember(GetMemberBinder binder, out object result)
27-
=> Storage.TryGetValue(binder.Name, out result);
28-
53+
public class RejectingSetDynamicObject : DynamicStorageObject
54+
{
2955
public override bool TrySetMember(SetMemberBinder binder, object value)
3056
{
57+
if (!Storage.ContainsKey(binder.Name))
58+
return false;
59+
3160
Storage[binder.Name] = value;
3261
return true;
3362
}
63+
}
64+
65+
public class ThrowingSetDynamicObject : DynamicStorageObject
66+
{
67+
public override bool TrySetMember(SetMemberBinder binder, object value)
68+
=> throw new InvalidOperationException($"TrySetMember failed for '{binder.Name}'");
69+
}
3470

71+
public class RejectingDeleteDynamicObject : DynamicStorageObject
72+
{
3573
public override bool TryDeleteMember(DeleteMemberBinder binder)
36-
=> binder is not null && Storage.Remove(binder.Name);
74+
{
75+
if (!Storage.ContainsKey(binder.Name))
76+
return false;
3777

38-
public override IEnumerable<string> GetDynamicMemberNames() => Storage.Keys;
78+
return Storage.Remove(binder.Name);
79+
}
80+
}
81+
82+
public class ThrowingDeleteDynamicObject : DynamicStorageObject
83+
{
84+
public override bool TryDeleteMember(DeleteMemberBinder binder)
85+
=> throw new InvalidOperationException($"TryDeleteMember failed for '{binder.Name}'");
3986
}

tests/test_dynamic.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
from System.Dynamic import ExpandoObject
66

77
from Python.Test import DynamicMappingObject
8+
from Python.Test import RejectingDeleteDynamicObject
9+
from Python.Test import RejectingSetDynamicObject
10+
from Python.Test import ThrowingDeleteDynamicObject
11+
from Python.Test import ThrowingSetDynamicObject
812

913

1014
def _mro_names(obj):
@@ -170,4 +174,36 @@ def custom_property(self, i):
170174
assert obj.custom_property == 10
171175

172176
obj.other_property = None
173-
assert obj.other_property is None
177+
assert obj.other_property is None
178+
179+
180+
def test_trysetmember_false_raises_attributeerror_instead_of_silent_python_setattr():
181+
obj = RejectingSetDynamicObject()
182+
183+
with pytest.raises(AttributeError):
184+
obj.typoed_name = 42
185+
186+
assert not hasattr(obj, "typoed_name")
187+
188+
189+
def test_trysetmember_exception_is_raised_in_python():
190+
obj = ThrowingSetDynamicObject()
191+
192+
with pytest.raises(Exception, match="TrySetMember failed for 'bad_name'"):
193+
obj.bad_name = 42
194+
195+
196+
def test_trydeletemember_false_raises_attributeerror():
197+
obj = RejectingDeleteDynamicObject()
198+
obj.AddDynamicMember("existing_name", 42)
199+
200+
with pytest.raises(AttributeError):
201+
del obj.missing_name
202+
203+
204+
def test_trydeletemember_exception_is_raised_in_python():
205+
obj = ThrowingDeleteDynamicObject()
206+
obj.bad_name = 42
207+
208+
with pytest.raises(Exception, match="TryDeleteMember failed for 'bad_name'"):
209+
del obj.bad_name

0 commit comments

Comments
 (0)