diff --git a/Common/Exceptions/NoMethodMatchPythonExceptionInterpreter.cs b/Common/Exceptions/NoMethodMatchPythonExceptionInterpreter.cs
index 65ba4adbc16d..90d2d6fbc795 100644
--- a/Common/Exceptions/NoMethodMatchPythonExceptionInterpreter.cs
+++ b/Common/Exceptions/NoMethodMatchPythonExceptionInterpreter.cs
@@ -24,6 +24,13 @@ namespace QuantConnect.Exceptions
///
public class NoMethodMatchPythonExceptionInterpreter : PythonExceptionInterpreter
{
+ ///
+ /// Attributes pythonnet attaches to the bind-failure TypeError with the data its
+ /// message is built from: the method name and the overloads hint block.
+ ///
+ private const string MethodNameAttribute = "_clr_method_name";
+ private const string OverloadsHintAttribute = "_clr_overloads_hint";
+
///
/// Determines the order that an instance of this class should be called
///
@@ -50,10 +57,12 @@ public override Exception Interpret(Exception exception, IExceptionInterpreter i
{
var pe = (PythonException)exception;
- var methodName = GetMethodName(pe.Message);
+ TryGetStructuredBindFailureData(pe, out var methodName, out var overloadsHint);
+
+ methodName ??= GetMethodName(pe.Message);
var message = Messages.NoMethodMatchPythonExceptionInterpreter.AttemptedToAccessMethodThatDoesNotExist(methodName);
- var overloadsHint = GetOverloadsHint(pe.Message);
+ overloadsHint ??= GetOverloadsHint(pe.Message);
if (!string.IsNullOrEmpty(overloadsHint))
{
message += $" {overloadsHint}";
@@ -64,6 +73,56 @@ public override Exception Interpret(Exception exception, IExceptionInterpreter i
return new MissingMethodException(message, pe);
}
+ ///
+ /// Reads the structured bind-failure data pythonnet attaches to the TypeError.
+ /// Outputs are null when the attributes are absent (older pythonnet) or unreadable,
+ /// so the caller falls back to parsing the message.
+ ///
+ private static void TryGetStructuredBindFailureData(PythonException exception, out string methodName,
+ out string overloadsHint)
+ {
+ methodName = null;
+ overloadsHint = null;
+
+ try
+ {
+ var value = exception.Value;
+ if (value == null)
+ {
+ return;
+ }
+
+ using (Py.GIL())
+ {
+ if (value.HasAttr(MethodNameAttribute))
+ {
+ using var nameAttribute = value.GetAttr(MethodNameAttribute);
+ methodName = nameAttribute.As();
+ }
+ if (value.HasAttr(OverloadsHintAttribute))
+ {
+ using var hintAttribute = value.GetAttr(OverloadsHintAttribute);
+ overloadsHint = hintAttribute.As();
+ }
+ }
+
+ // Empty means absent so the caller's null-coalescing fallback kicks in
+ if (string.IsNullOrEmpty(methodName))
+ {
+ methodName = null;
+ }
+ if (string.IsNullOrEmpty(overloadsHint))
+ {
+ overloadsHint = null;
+ }
+ }
+ catch
+ {
+ methodName = null;
+ overloadsHint = null;
+ }
+ }
+
///
/// Extracts the name of the method that failed to resolve from the Python exception message.
/// The message has the form: "No method matches given arguments for {methodName}: ({argumentTypes})",
diff --git a/Tests/Common/Exceptions/NoMethodMatchPythonExceptionInterpreterTests.cs b/Tests/Common/Exceptions/NoMethodMatchPythonExceptionInterpreterTests.cs
index 7f0c4d0aa5f3..18fb8cc7d7ca 100644
--- a/Tests/Common/Exceptions/NoMethodMatchPythonExceptionInterpreterTests.cs
+++ b/Tests/Common/Exceptions/NoMethodMatchPythonExceptionInterpreterTests.cs
@@ -159,6 +159,45 @@ public void VerifyMessageKeepsTheExpectedSignatureHint()
Assert.That(exception.Message, Does.Contain("rsi("));
}
+ [Test]
+ public void InterpretPrefersStructuredAttributesOverMessageParsing()
+ {
+ // The fixture's message names 'parsed_name' but its attributes name 'structured_name'
+ var pythonException = ThrowFixtureMethod("no_method_match_with_structured_attributes");
+
+ var interpreter = new NoMethodMatchPythonExceptionInterpreter();
+ var exception = interpreter.Interpret(pythonException, NullExceptionInterpreter.Instance);
+
+ Assert.That(exception.Message, Does.Contain("required by the structured_name method"));
+ Assert.That(exception.Message, Does.Contain("The expected signature is:"));
+ Assert.That(exception.Message, Does.Contain("structured_name(x: int)"));
+ Assert.That(exception.Message, Does.Not.Contain("parsed_name"));
+ }
+
+ [Test]
+ public void InterpretFallsBackToMessageParsingWithoutStructuredAttributes()
+ {
+ // Same message shape but no attributes, as raised by older pythonnet versions
+ var pythonException = ThrowFixtureMethod("no_method_match_without_structured_attributes");
+
+ var interpreter = new NoMethodMatchPythonExceptionInterpreter();
+ var exception = interpreter.Interpret(pythonException, NullExceptionInterpreter.Instance);
+
+ Assert.That(exception.Message, Does.Contain("required by the parsed_name method"));
+ Assert.That(exception.Message, Does.Contain("The following overloads are available:"));
+ Assert.That(exception.Message, Does.Contain("parsed_name(x: str)"));
+ }
+
+ private static PythonException ThrowFixtureMethod(string methodName)
+ {
+ using (Py.GIL())
+ {
+ var module = Py.Import("Test_PythonExceptionInterpreter");
+ var algorithm = module.GetAttr("Test_PythonExceptionInterpreter").Invoke();
+ return Assert.Throws(() => algorithm.GetAttr(methodName).Invoke());
+ }
+ }
+
private Exception CreateExceptionFromType(Type type) => type == typeof(PythonException) ? _pythonException : (Exception)Activator.CreateInstance(type);
}
}
diff --git a/Tests/RegressionAlgorithms/Test_PythonExceptionInterpreter.py b/Tests/RegressionAlgorithms/Test_PythonExceptionInterpreter.py
index de3b05ca8faf..ab65c6042921 100644
--- a/Tests/RegressionAlgorithms/Test_PythonExceptionInterpreter.py
+++ b/Tests/RegressionAlgorithms/Test_PythonExceptionInterpreter.py
@@ -29,6 +29,19 @@ def no_method_match_rsi(self):
# so no RSI overload matches the given arguments.
self._indicator = self.rsi(symbol, 15, Resolution.DAILY)
+ def no_method_match_with_structured_attributes(self):
+ # A bind-failure TypeError whose attributes name a different method than its message
+ error = TypeError("No method matches given arguments for parsed_name: (). "
+ "The following overloads are available:\n parsed_name(x: str)")
+ error._clr_method_name = 'structured_name'
+ error._clr_overloads_hint = 'The expected signature is:\n structured_name(x: int)'
+ raise error
+
+ def no_method_match_without_structured_attributes(self):
+ # The message-only shape raised by pythonnet versions without the attributes
+ raise TypeError("No method matches given arguments for parsed_name: (). "
+ "The following overloads are available:\n parsed_name(x: str)")
+
def unsupported_operand(self):
x = None + "Pepe Grillo"