From 998138b289e7c504ee6c172d2908be7dd9592d1c Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Wed, 15 Jul 2026 17:40:37 -0400 Subject: [PATCH 1/2] Set a TypeError when a conversion to a CLR class fails with setError Converter.ToManagedValue had three exits that reported failure without setting a Python error: the final fall-through when a plain Python object has no conversion to the target CLR class, the ClassBase exit when a reflected class object is converted to something other than Type, and the exit for managed values that are neither CLRObject, ClassBase, nor MethodBinding (e.g. a MethodObject). Callers that pass setError true, such as PropertyObject.tp_descr_set, trust the failure to carry a pending error and return -1, so CPython raised "SystemError: error return without exception set" instead of a useful message. Assigning a pure Python object to a C# property of a CLR class type reproduced this. These exits now set the conventional TypeError ("'' value cannot be converted to ") when setError is true and no more specific error is already pending from a probing sub-path; setError false behavior is unchanged. --- src/embed_tests/TestConverter.cs | 69 +++++++++++++++++++++++++++ src/embed_tests/TestPropertyAccess.cs | 30 ++++++++++++ src/runtime/Converter.cs | 19 ++++++++ tests/test_array.py | 8 ++-- tests/test_delegate.py | 6 +-- 5 files changed, 124 insertions(+), 8 deletions(-) diff --git a/src/embed_tests/TestConverter.cs b/src/embed_tests/TestConverter.cs index 1355b2247..3f711f62c 100644 --- a/src/embed_tests/TestConverter.cs +++ b/src/embed_tests/TestConverter.cs @@ -546,6 +546,75 @@ public void OverflowConversionOnlyLeavesErrorWhenSettingErrors(bool setError) Assert.AreEqual(setError, Exceptions.ErrorOccurred()); Exceptions.Clear(); } + + [TestCase(true)] + [TestCase(false)] + public void FailedConversionToClrClassOnlyLeavesErrorWhenSettingErrors(bool setError) + { + using var _ = Py.GIL(); + + var module = PyModule.FromString("FailedConversionToClrClassModule", @" +class PurePythonValue: + pass +"); + using var instance = module.GetAttr("PurePythonValue").Invoke(); + + Assert.IsFalse(Converter.ToManaged(instance, typeof(UriBuilder), out var result, setError)); + Assert.IsNull(result); + Assert.AreEqual(setError, Exceptions.ErrorOccurred()); + if (setError) + { + Assert.IsTrue(Exceptions.ExceptionMatches(Exceptions.TypeError)); + } + Exceptions.Clear(); + } + + [TestCase(true)] + [TestCase(false)] + public void FailedConversionOfClassObjectOnlyLeavesErrorWhenSettingErrors(bool setError) + { + using var _ = Py.GIL(); + + var module = PyModule.FromString("FailedConversionOfClassObjectModule", @" +from System import Uri +"); + using var classObject = module.GetAttr("Uri"); + + Assert.IsFalse(Converter.ToManaged(classObject, typeof(UriBuilder), out var result, setError)); + Assert.IsNull(result); + Assert.AreEqual(setError, Exceptions.ErrorOccurred()); + if (setError) + { + Assert.IsTrue(Exceptions.ExceptionMatches(Exceptions.TypeError)); + } + Exceptions.Clear(); + } + + [TestCase(true)] + [TestCase(false)] + public void FailedConversionOfNonClrValueManagedTypesOnlyLeavesErrorWhenSettingErrors(bool setError) + { + using var _ = Py.GIL(); + + // a CLR namespace module is a managed type that is neither a CLRObject, + // a class, nor a method binding; a class attribute access yields a + // MethodBinding that reaches the final conversion fall-through + using var systemModule = Py.Import("System"); + using var uriClass = systemModule.GetAttr("Uri"); + using var compareBinding = uriClass.GetAttr("Compare"); + + foreach (var value in new[] { systemModule, compareBinding }) + { + Assert.IsFalse(Converter.ToManaged(value, typeof(UriBuilder), out var result, setError)); + Assert.IsNull(result); + Assert.AreEqual(setError, Exceptions.ErrorOccurred()); + if (setError) + { + Assert.IsTrue(Exceptions.ExceptionMatches(Exceptions.TypeError)); + } + Exceptions.Clear(); + } + } } public interface IGetList diff --git a/src/embed_tests/TestPropertyAccess.cs b/src/embed_tests/TestPropertyAccess.cs index 1c9d0e7fd..e78c777c0 100644 --- a/src/embed_tests/TestPropertyAccess.cs +++ b/src/embed_tests/TestPropertyAccess.cs @@ -52,6 +52,8 @@ public class Fixture public static readonly string PublicStaticReadOnlyField = "Default value"; protected static readonly string ProtectedStaticReadOnlyField = "Default value"; + public Fixture ObjectProperty { get; set; } + public static Fixture Create() { return new Fixture(); @@ -291,6 +293,34 @@ def SetValue(self, fixture): } } + [Test] + public void TestSetPropertyNonConvertibleValueRaisesTypeError() + { + dynamic model = PyModule.FromString("module", @" +from clr import AddReference +AddReference(""System"") +AddReference(""Python.EmbeddingTest"") + +from Python.EmbeddingTest import * + +class PurePythonValue: + pass + +class TestSetPropertyNonConvertibleValueRaisesTypeError: + def SetValue(self, fixture): + fixture.ObjectProperty = PurePythonValue() +").GetAttr("TestSetPropertyNonConvertibleValueRaisesTypeError").Invoke(); + + var fixture = new Fixture(); + + using (Py.GIL()) + { + var exception = Assert.Throws(() => model.SetValue(fixture)); + Assert.AreEqual("TypeError", exception.Type.Name); + StringAssert.Contains("value cannot be converted to", exception.Message); + } + } + [Test] public void TestGetPublicReadOnlyPropertyFailsWhenAccessedOnClass() { diff --git a/src/runtime/Converter.cs b/src/runtime/Converter.cs index 5b5416a46..51dbed7fe 100644 --- a/src/runtime/Converter.cs +++ b/src/runtime/Converter.cs @@ -526,6 +526,7 @@ internal static bool ToManagedValue(BorrowedReference value, Type obType, // The value being converted is a class type, so it will only succeed if it's being converted into a Type if (obType != typeof(Type)) { + SetConversionError(value, obType, setError); return false; } @@ -540,6 +541,7 @@ internal static bool ToManagedValue(BorrowedReference value, Type obType, // Method bindings will be handled below along with actual Python callables if (mt is not MethodBinding) { + SetConversionError(value, obType, setError); return false; } } @@ -723,9 +725,26 @@ internal static bool ToManagedValue(BorrowedReference value, Type obType, } } + SetConversionError(value, obType, setError); return false; } + /// + /// Sets a TypeError for a failed conversion, unless the caller did not ask for + /// errors or a probing sub-path already raised a more specific error that must + /// be surfaced instead. Callers that report failure with setError true must + /// always leave a Python error pending, otherwise CPython turns the bare error + /// return into a "SystemError: error return without exception set". + /// + static void SetConversionError(BorrowedReference value, Type obType, bool setError) + { + if (setError && !Exceptions.ErrorOccurred()) + { + string tpName = Runtime.PyObject_GetTypeName(value); + Exceptions.SetError(Exceptions.TypeError, $"'{tpName}' value cannot be converted to {obType}"); + } + } + static bool EncodableByUser(Type type, object value) { // When no encoders are registered (the common case) skip the type diff --git a/tests/test_array.py b/tests/test_array.py index 2ac234351..106a4d3e1 100644 --- a/tests/test_array.py +++ b/tests/test_array.py @@ -761,8 +761,6 @@ def test_null_array(): ob = Test.NullArrayTest() _ = ob.items["wrong"] -# TODO: Error Type should be TypeError for all cases -# Currently throws SystemErrors instead def test_interface_array(): """Test interface arrays.""" from Python.Test import Spam @@ -789,7 +787,7 @@ def test_interface_array(): items[0] = None assert items[0] is None - with pytest.raises(SystemError): + with pytest.raises(TypeError): ob = Test.InterfaceArrayTest() ob.items[0] = 99 @@ -797,7 +795,7 @@ def test_interface_array(): ob = Test.InterfaceArrayTest() _ = ob.items["wrong"] - with pytest.raises(SystemError): + with pytest.raises(TypeError): ob = Test.InterfaceArrayTest() ob.items["wrong"] = "wrong" @@ -828,7 +826,7 @@ def test_typed_array(): items[0] = None assert items[0] is None - with pytest.raises(SystemError): + with pytest.raises(TypeError): ob = Test.TypedArrayTest() ob.items[0] = 99 diff --git a/tests/test_delegate.py b/tests/test_delegate.py index 1430ac4ae..1b34cb975 100644 --- a/tests/test_delegate.py +++ b/tests/test_delegate.py @@ -279,9 +279,9 @@ def test_invalid_object_delegate(): d = ObjectDelegate(hello_func) ob = DelegateTest() - # QuantConnect fork: a mismatched delegate return surfaces as a .NET - # InvalidOperationException rather than a Python SystemError. - with pytest.raises(System.InvalidOperationException): + # The mismatched delegate return fails its conversion to the expected + # type, which surfaces as a TypeError describing the failed conversion. + with pytest.raises(TypeError): ob.CallObjectDelegate(d) def test_out_int_delegate(): From 760e34618fb936e340d1104236fe9b5075ad4cdf Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Wed, 15 Jul 2026 18:51:33 -0400 Subject: [PATCH 2/2] Update version to 2.0.64 Bump package , AssemblyVersion/AssemblyFileVersion and the perf-test baseline reference to 2.0.64. --- src/perf_tests/Python.PerformanceTests.csproj | 4 ++-- src/runtime/Properties/AssemblyInfo.cs | 4 ++-- src/runtime/Python.Runtime.csproj | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/perf_tests/Python.PerformanceTests.csproj b/src/perf_tests/Python.PerformanceTests.csproj index d5f1deda3..e72948e95 100644 --- a/src/perf_tests/Python.PerformanceTests.csproj +++ b/src/perf_tests/Python.PerformanceTests.csproj @@ -13,7 +13,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + compile @@ -25,7 +25,7 @@ - + diff --git a/src/runtime/Properties/AssemblyInfo.cs b/src/runtime/Properties/AssemblyInfo.cs index 6cefd7a41..3700bd52c 100644 --- a/src/runtime/Properties/AssemblyInfo.cs +++ b/src/runtime/Properties/AssemblyInfo.cs @@ -4,5 +4,5 @@ [assembly: InternalsVisibleTo("Python.EmbeddingTest, PublicKey=00240000048000009400000006020000002400005253413100040000110000005ffd8f49fb44ab0641b3fd8d55e749f716e6dd901032295db641eb98ee46063cbe0d4a1d121ef0bc2af95f8a7438d7a80a3531316e6b75c2dae92fb05a99f03bf7e0c03980e1c3cfb74ba690aca2f3339ef329313bcc5dccced125a4ffdc4531dcef914602cd5878dc5fbb4d4c73ddfbc133f840231343e013762884d6143189")] [assembly: InternalsVisibleTo("Python.Test, PublicKey=00240000048000009400000006020000002400005253413100040000110000005ffd8f49fb44ab0641b3fd8d55e749f716e6dd901032295db641eb98ee46063cbe0d4a1d121ef0bc2af95f8a7438d7a80a3531316e6b75c2dae92fb05a99f03bf7e0c03980e1c3cfb74ba690aca2f3339ef329313bcc5dccced125a4ffdc4531dcef914602cd5878dc5fbb4d4c73ddfbc133f840231343e013762884d6143189")] -[assembly: AssemblyVersion("2.0.63")] -[assembly: AssemblyFileVersion("2.0.63")] +[assembly: AssemblyVersion("2.0.64")] +[assembly: AssemblyFileVersion("2.0.64")] diff --git a/src/runtime/Python.Runtime.csproj b/src/runtime/Python.Runtime.csproj index 52dff414f..cf86a3f28 100644 --- a/src/runtime/Python.Runtime.csproj +++ b/src/runtime/Python.Runtime.csproj @@ -5,7 +5,7 @@ Python.Runtime Python.Runtime QuantConnect.pythonnet - 2.0.63 + 2.0.64 false LICENSE https://github.com/pythonnet/pythonnet