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"