Skip to content

Commit 738527d

Browse files
authored
Add ClrBubbledException to handle exceptions bubbled from .Net to Python (#76)
* Add ClrBubbledException to handle exceptions bubbled from .Net to Python and back to .Net * Bump version to 2.0.23 * Minor unit tests fixes
1 parent c13350f commit 738527d

File tree

10 files changed

+172
-18
lines changed

10 files changed

+172
-18
lines changed

src/embed_tests/Codecs.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,8 +335,9 @@ public void ExceptionDecoded()
335335
{
336336
PyObjectConversions.RegisterDecoder(new ValueErrorCodec());
337337
using var scope = Py.CreateScope();
338-
var error = Assert.Throws<ValueErrorWrapper>(()
338+
var error = Assert.Throws<ClrBubbledException>(()
339339
=> PythonEngine.Exec($"raise ValueError('{TestExceptionMessage}')"));
340+
Assert.IsInstanceOf<ValueErrorWrapper>(error.InnerException);
340341
Assert.AreEqual(TestExceptionMessage, error.Message);
341342
}
342343

src/embed_tests/TestPropertyAccess.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1339,7 +1339,9 @@ def CallDynamicMethodCatchingExceptions(self, fixture, defaultValue):
13391339

13401340
using (Py.GIL())
13411341
{
1342-
Assert.Throws<ArgumentException>(() => model.CallDynamicMethodWithoutCatchingExceptions(fixture));
1342+
var exception = Assert.Throws<ClrBubbledException>(() => model.CallDynamicMethodWithoutCatchingExceptions(fixture));
1343+
Assert.IsInstanceOf<ArgumentException>(exception.InnerException);
1344+
13431345
Assert.AreEqual(property, model.CallDynamicMethodCatchingExceptions(fixture, property).AsManagedObject(property.GetType()));
13441346
}
13451347
}

src/embed_tests/TestPythonException.cs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
using System;
2+
using System.IO;
3+
using System.Linq;
4+
25
using NUnit.Framework;
36
using Python.Runtime;
47

@@ -10,6 +13,16 @@ public class TestPythonException
1013
public void SetUp()
1114
{
1215
PythonEngine.Initialize();
16+
17+
// Add scripts folder to path in order to be able to import the test modules
18+
string testPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "fixtures");
19+
TestContext.Out.WriteLine(testPath);
20+
21+
using var str = Runtime.Runtime.PyString_FromString(testPath);
22+
Assert.IsFalse(str.IsNull());
23+
BorrowedReference path = Runtime.Runtime.PySys_GetObject("path");
24+
Assert.IsFalse(path.IsNull);
25+
Runtime.Runtime.PyList_Append(path, str.Borrow());
1326
}
1427

1528
[OneTimeTearDown]
@@ -195,5 +208,69 @@ public void TestPythonException_Normalize_ThrowsWhenErrorSet()
195208
Assert.Throws<InvalidOperationException>(() => pythonException.Normalize());
196209
Exceptions.Clear();
197210
}
211+
212+
[Test]
213+
public void TestGetsPythonCodeInfoInStackTrace()
214+
{
215+
using (Py.GIL())
216+
{
217+
dynamic testClassModule = PyModule.FromString("TestGetsPythonCodeInfoInStackTrace_Module", @"
218+
from clr import AddReference
219+
AddReference(""Python.EmbeddingTest"")
220+
221+
from Python.EmbeddingTest import *
222+
223+
class TestPythonClass(TestPythonException.TestClass):
224+
def CallThrow(self):
225+
super().ThrowException()
226+
");
227+
228+
try
229+
{
230+
var instance = testClassModule.TestPythonClass();
231+
dynamic module = Py.Import("PyImportTest.SampleScript");
232+
module.invokeMethod(instance, "CallThrow");
233+
}
234+
catch (ClrBubbledException ex)
235+
{
236+
Assert.AreEqual("Test Exception Message", ex.InnerException.Message);
237+
238+
var pythonTracebackLines = ex.PythonTraceback.TrimEnd('\n').Split('\n').Select(x => x.Trim()).ToList();
239+
Assert.AreEqual(5, pythonTracebackLines.Count);
240+
241+
Assert.AreEqual("File \"none\", line 9, in CallThrow", pythonTracebackLines[0]);
242+
243+
Assert.IsTrue(new[]
244+
{
245+
"File ",
246+
"fixtures\\PyImportTest\\SampleScript.py",
247+
"line 5",
248+
"in invokeMethodImpl"
249+
}.All(x => pythonTracebackLines[1].Contains(x)));
250+
Assert.AreEqual("getattr(instance, method_name)()", pythonTracebackLines[2]);
251+
252+
Assert.IsTrue(new[]
253+
{
254+
"File ",
255+
"fixtures\\PyImportTest\\SampleScript.py",
256+
"line 2",
257+
"in invokeMethod"
258+
}.All(x => pythonTracebackLines[3].Contains(x)));
259+
Assert.AreEqual("invokeMethodImpl(instance, method_name)", pythonTracebackLines[4]);
260+
}
261+
catch (Exception ex)
262+
{
263+
Assert.Fail($"Unexpected exception: {ex}");
264+
}
265+
}
266+
}
267+
268+
public class TestClass
269+
{
270+
public void ThrowException()
271+
{
272+
throw new ArgumentException("Test Exception Message");
273+
}
274+
}
198275
}
199276
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
def invokeMethod(instance, method_name):
2+
invokeMethodImpl(instance, method_name)
3+
4+
def invokeMethodImpl(instance, method_name):
5+
getattr(instance, method_name)()

src/embed_tests/pyimport.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@ import clr
9696
clr.AddReference('{path}')
9797
";
9898

99-
Assert.Throws<BadImageFormatException>(() => PythonEngine.Exec(code));
99+
var exception = Assert.Throws<ClrBubbledException>(() => PythonEngine.Exec(code));
100+
Assert.IsInstanceOf<BadImageFormatException>(exception.InnerException);
100101
}
101102
}
102103
}

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

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

src/runtime/ClrBubbledException.cs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
using System;
2+
using System.Text;
3+
4+
namespace Python.Runtime
5+
{
6+
/// <summary>
7+
/// Provides an abstraction to represent a .Net exception that is bubbled to Python and back to .Net
8+
/// and includes the Python traceback.
9+
/// </summary>
10+
public class ClrBubbledException : Exception
11+
{
12+
/// <summary>
13+
/// The Python traceback
14+
/// </summary>
15+
public string PythonTraceback { get; }
16+
17+
/// <summary>
18+
/// Creates a new instance of <see cref="ClrBubbledException"/>
19+
/// </summary>
20+
/// <param name="sourceException">The original exception that was thrown in .Net</param>
21+
/// <param name="pythonTraceback">The Python traceback</param>
22+
public ClrBubbledException(Exception sourceException, string pythonTraceback)
23+
: base(sourceException.Message, sourceException)
24+
{
25+
PythonTraceback = pythonTraceback;
26+
}
27+
28+
/// <summary>
29+
/// StackTrace Property
30+
/// </summary>
31+
/// <remarks>
32+
/// A string representing the exception stack trace.
33+
/// </remarks>
34+
public override string StackTrace
35+
{
36+
get
37+
{
38+
return PythonTraceback + "Underlying exception stack trace:" + Environment.NewLine + InnerException.StackTrace;
39+
}
40+
}
41+
42+
public override string ToString()
43+
{
44+
StringBuilder description = new StringBuilder();
45+
description.AppendFormat("{0}: {1}{2}", InnerException.GetType().Name, Message, Environment.NewLine);
46+
description.AppendFormat(" --> {0}", PythonTraceback);
47+
description.AppendFormat(" --- End of Python traceback ---{0}", Environment.NewLine);
48+
49+
if (InnerException.InnerException != null)
50+
{
51+
description.AppendFormat(" ---> {0}", InnerException.InnerException);
52+
description.AppendFormat("{0} --- End of inner exception stack trace ---{0}", Environment.NewLine);
53+
}
54+
55+
description.Append(InnerException.StackTrace);
56+
description.AppendFormat("{0} --- End of underlying exception ---", Environment.NewLine);
57+
58+
var str = description.ToString();
59+
return str;
60+
}
61+
}
62+
}

src/runtime/Properties/AssemblyInfo.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@
44
[assembly: InternalsVisibleTo("Python.EmbeddingTest, PublicKey=00240000048000009400000006020000002400005253413100040000110000005ffd8f49fb44ab0641b3fd8d55e749f716e6dd901032295db641eb98ee46063cbe0d4a1d121ef0bc2af95f8a7438d7a80a3531316e6b75c2dae92fb05a99f03bf7e0c03980e1c3cfb74ba690aca2f3339ef329313bcc5dccced125a4ffdc4531dcef914602cd5878dc5fbb4d4c73ddfbc133f840231343e013762884d6143189")]
55
[assembly: InternalsVisibleTo("Python.Test, PublicKey=00240000048000009400000006020000002400005253413100040000110000005ffd8f49fb44ab0641b3fd8d55e749f716e6dd901032295db641eb98ee46063cbe0d4a1d121ef0bc2af95f8a7438d7a80a3531316e6b75c2dae92fb05a99f03bf7e0c03980e1c3cfb74ba690aca2f3339ef329313bcc5dccced125a4ffdc4531dcef914602cd5878dc5fbb4d4c73ddfbc133f840231343e013762884d6143189")]
66

7-
[assembly: AssemblyVersion("2.0.22")]
8-
[assembly: AssemblyFileVersion("2.0.22")]
7+
[assembly: AssemblyVersion("2.0.23")]
8+
[assembly: AssemblyFileVersion("2.0.23")]

src/runtime/Python.Runtime.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<RootNamespace>Python.Runtime</RootNamespace>
66
<AssemblyName>Python.Runtime</AssemblyName>
77
<PackageId>QuantConnect.pythonnet</PackageId>
8-
<Version>2.0.22</Version>
8+
<Version>2.0.23</Version>
99
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
1010
<PackageLicenseFile>LICENSE</PackageLicenseFile>
1111
<RepositoryUrl>https://github.com/pythonnet/pythonnet</RepositoryUrl>

src/runtime/PythonException.cs

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -163,26 +163,32 @@ private static Exception FromPyErr(BorrowedReference typeRef, BorrowedReference
163163
var value = new PyObject(valRef);
164164
var traceback = PyObject.FromNullableReference(tbRef);
165165

166+
Exception exception = null;
167+
166168
exceptionDispatchInfo = TryGetDispatchInfo(valRef);
167169
if (exceptionDispatchInfo != null)
168170
{
169-
return exceptionDispatchInfo.SourceException;
171+
exception = exceptionDispatchInfo.SourceException;
172+
exceptionDispatchInfo = null;
170173
}
171-
172-
if (ManagedType.GetManagedObject(valRef) is CLRObject { inst: Exception e })
174+
else if (ManagedType.GetManagedObject(valRef) is CLRObject { inst: Exception e })
173175
{
174-
return e;
176+
exception = e;
175177
}
176-
177-
if (TryDecodePyErr(typeRef, valRef, tbRef) is { } pyErr)
178+
else if (TryDecodePyErr(typeRef, valRef, tbRef) is { } pyErr)
178179
{
179-
return pyErr;
180+
exception = pyErr;
180181
}
181-
182-
if (PyObjectConversions.TryDecode(valRef, typeRef, typeof(Exception), out object? decoded)
182+
else if (PyObjectConversions.TryDecode(valRef, typeRef, typeof(Exception), out object? decoded)
183183
&& decoded is Exception decodedException)
184184
{
185-
return decodedException;
185+
exception = decodedException;
186+
}
187+
188+
if (!(exception is null))
189+
{
190+
using var _ = new Py.GILState();
191+
return new ClrBubbledException(exception, TracebackToString(traceback));
186192
}
187193

188194
using var cause = Runtime.PyException_GetCause(nValRef);

0 commit comments

Comments
 (0)