diff --git a/src/runtime/Exceptions.cs b/src/runtime/Exceptions.cs index c3ac889ed..9b5e0e3d1 100644 --- a/src/runtime/Exceptions.cs +++ b/src/runtime/Exceptions.cs @@ -179,6 +179,20 @@ public static void SetError(BorrowedReference type, BorrowedReference exceptionO } internal const string DispatchInfoAttribute = "__dispatch_info__"; + + /// + /// Attributes attached to the bind-failure TypeError (see MethodBinder) carrying the + /// data its message is built from, so consumers do not have to parse the message. + /// Must stay internal: Initialize() resolves every public static field of this class + /// against the builtins module. + /// + internal const string BindFailureMethodNameAttribute = "_clr_method_name"; + + /// + internal const string BindFailureSignaturesAttribute = "_clr_overload_signatures"; + + /// + internal const string BindFailureOverloadsHintAttribute = "_clr_overloads_hint"; /// /// SetError Method /// diff --git a/src/runtime/MethodBinder.cs b/src/runtime/MethodBinder.cs index a20624d2b..a1dcf7022 100644 --- a/src/runtime/MethodBinder.cs +++ b/src/runtime/MethodBinder.cs @@ -1016,15 +1016,21 @@ internal virtual NewReference Invoke(BorrowedReference inst, BorrowedReference a // If we already have an exception pending, don't create a new one if (!Exceptions.ErrorOccurred()) { - var value = new StringBuilder("No method matches given arguments"); // Use the snake_case name Python callers use, matching the hinted signatures below. + string methodName = null; if (methodinfo != null && methodinfo.Length > 0) { - value.Append($" for {MethodSignatureFormatter.SnakeCaseName(methodinfo[0])}"); + methodName = MethodSignatureFormatter.SnakeCaseName(methodinfo[0]); } else if (list.Count > 0) { - value.Append($" for {MethodSignatureFormatter.SnakeCaseName(list[0].MethodBase)}"); + methodName = MethodSignatureFormatter.SnakeCaseName(list[0].MethodBase); + } + + var value = new StringBuilder("No method matches given arguments"); + if (methodName != null) + { + value.Append($" for {methodName}"); } value.Append(": "); @@ -1036,13 +1042,14 @@ internal virtual NewReference Invoke(BorrowedReference inst, BorrowedReference a var candidates = methodinfo != null && methodinfo.Length > 0 ? methodinfo.Cast() : list?.Select(m => m.MethodBase); - var overloads = MethodSignatureFormatter.FormatOverloads(candidates); - if (overloads.Length > 0) + var signatures = MethodSignatureFormatter.GetSignatures(candidates); + var overloadsHint = MethodSignatureFormatter.FormatOverloadsHint(signatures); + if (overloadsHint.Length > 0) { - value.Append(". ").Append(overloads); + value.Append(". ").Append(overloadsHint); } - Exceptions.RaiseTypeError(value.ToString()); + RaiseBindFailure(value.ToString(), methodName, signatures, overloadsHint); } return default; @@ -1123,6 +1130,87 @@ internal virtual NewReference Invoke(BorrowedReference inst, BorrowedReference a return Converter.ToPython(result, returnType); } + /// + /// Raises the bind-failure TypeError, attaching the method name, signatures and + /// overloads hint as attributes (the Exceptions.BindFailure*Attribute constants). + /// Best-effort: on any failure the plain TypeError with the same message remains set. + /// + private static void RaiseBindFailure(string message, string methodName, IReadOnlyList signatures, string overloadsHint) + { + Exceptions.SetError(Exceptions.TypeError, message); + if (methodName == null && (signatures == null || signatures.Count == 0)) + { + return; + } + + try + { + Runtime.PyErr_Fetch(out var errType, out var errVal, out var errTb); + try + { + Runtime.PyErr_NormalizeException(ref errType, ref errVal, ref errTb); + + if (!errVal.IsNull()) + { + var instance = errVal.Borrow(); + + if (methodName != null) + { + using var namePy = Runtime.PyString_FromString(methodName); + if (!namePy.IsNull()) + { + Runtime.PyObject_SetAttrString(instance, Exceptions.BindFailureMethodNameAttribute, namePy.Borrow()); + } + } + + if (signatures != null && signatures.Count > 0) + { + using var tuple = Runtime.PyTuple_New(signatures.Count); + var populated = !tuple.IsNull(); + for (var i = 0; i < signatures.Count && populated; i++) + { + using var signature = Runtime.PyString_FromString(signatures[i]); + populated = !signature.IsNull() + && Runtime.PyTuple_SetItem(tuple.Borrow(), i, signature.Borrow()) == 0; + } + + if (populated) + { + Runtime.PyObject_SetAttrString(instance, Exceptions.BindFailureSignaturesAttribute, tuple.Borrow()); + + if (!string.IsNullOrEmpty(overloadsHint)) + { + using var hintPy = Runtime.PyString_FromString(overloadsHint); + if (!hintPy.IsNull()) + { + Runtime.PyObject_SetAttrString(instance, Exceptions.BindFailureOverloadsHintAttribute, hintPy.Borrow()); + } + } + } + } + } + + // A failed attribute set must not replace the bind failure with its own error + if (Exceptions.ErrorOccurred()) + { + Runtime.PyErr_Clear(); + } + } + finally + { + Runtime.PyErr_Restore(errType.StealNullable(), errVal.StealNullable(), errTb.StealNullable()); + } + } + catch + { + // The error state may have been consumed by the failed fetch/restore + if (!Exceptions.ErrorOccurred()) + { + Exceptions.SetError(Exceptions.TypeError, message); + } + } + } + /// /// Utility class to store the information about a /// diff --git a/src/runtime/MethodSignatureFormatter.cs b/src/runtime/MethodSignatureFormatter.cs index a382ee172..476be4627 100644 --- a/src/runtime/MethodSignatureFormatter.cs +++ b/src/runtime/MethodSignatureFormatter.cs @@ -28,14 +28,22 @@ public static class MethodSignatureFormatter /// Optional name to display for the methods, e.g. the type /// name for constructors instead of the special .ctor token public static string FormatOverloads(IEnumerable methods, int maxShown = 10, string displayName = null) + { + return FormatOverloadsHint(GetSignatures(methods, displayName), maxShown); + } + + /// + /// The distinct formatted signatures of the candidate overloads, in order, with the + /// PyObject-overload filtering described on . Never + /// throws: it only runs on error paths and must not mask the original failure. + /// + internal static IReadOnlyList GetSignatures(IEnumerable methods, string displayName = null) { if (methods == null) { - return string.Empty; + return Array.Empty(); } - // Building this only runs on error paths; never let it throw and mask - // the original failure. try { var candidates = methods.Where(method => method != null).ToList(); @@ -45,8 +53,6 @@ public static string FormatOverloads(IEnumerable methods, int maxSho candidates = withoutPyObject; } - // Distinct signatures, preserving order. Snake-cased duplicates and - // repeated overloads collapse into a single entry. var signatures = new List(); var seen = new HashSet(); foreach (var method in candidates) @@ -58,29 +64,39 @@ public static string FormatOverloads(IEnumerable methods, int maxSho } } - if (signatures.Count == 0) - { - return string.Empty; - } - - var to = new StringBuilder(signatures.Count == 1 - ? "The expected signature is:" - : "The following overloads are available:"); - for (var i = 0; i < signatures.Count && i < maxShown; i++) - { - to.Append("\n ").Append(signatures[i]); - } - if (signatures.Count > maxShown) - { - to.Append($"\n ... and {signatures.Count - maxShown} more"); - } - return to.ToString(); + return signatures; } catch { // Best-effort hint only. + return Array.Empty(); + } + } + + /// + /// Renders the signatures from as the hint block appended + /// to bind-failure messages: a header plus one signature per line, capped at + /// . + /// + internal static string FormatOverloadsHint(IReadOnlyList signatures, int maxShown = 10) + { + if (signatures == null || signatures.Count == 0) + { return string.Empty; } + + var to = new StringBuilder(signatures.Count == 1 + ? "The expected signature is:" + : "The following overloads are available:"); + for (var i = 0; i < signatures.Count && i < maxShown; i++) + { + to.Append("\n ").Append(signatures[i]); + } + if (signatures.Count > maxShown) + { + to.Append($"\n ... and {signatures.Count - maxShown} more"); + } + return to.ToString(); } /// diff --git a/tests/test_method.py b/tests/test_method.py index 07b5c5a34..6f9272490 100644 --- a/tests/test_method.py +++ b/tests/test_method.py @@ -1255,6 +1255,47 @@ def test_params_array_overloaded_failing(): res = MethodTest.ParamsArrayOverloaded(paramsArray=[], i=1) assert res == "with params-array" +def test_bind_failure_structured_attributes(): + """A bind-failure TypeError carries the method name, overload signatures + and rendered overloads hint as attributes, matching the message.""" + with pytest.raises(TypeError) as excinfo: + MethodTest.TestOverloadedParams({}, "x") + e = excinfo.value + + assert e._clr_method_name == "test_overloaded_params" + + signatures = e._clr_overload_signatures + assert isinstance(signatures, tuple) + assert len(signatures) > 1 + assert all(isinstance(s, str) and s.startswith("test_overloaded_params(") + for s in signatures) + + hint = e._clr_overloads_hint + assert hint.startswith("The following overloads are available:") + for signature in signatures: + assert signature in hint + + # The message itself is unchanged: prefix + argument types + the same hint + message = str(e) + assert message.startswith( + "No method matches given arguments for test_overloaded_params: ") + assert "(, )" in message + assert message.endswith(hint) + + +def test_bind_failure_structured_attributes_single_overload(): + """Single-overload failures use the singular hint header and still carry + the structured attributes.""" + with pytest.raises(TypeError) as excinfo: + MethodTest.TestOverloadedNoObject("foo") + e = excinfo.value + + assert e._clr_method_name == "test_overloaded_no_object" + assert e._clr_overload_signatures == ("test_overloaded_no_object(i: int)",) + assert e._clr_overloads_hint.startswith("The expected signature is:") + assert str(e).endswith(e._clr_overloads_hint) + + def test_method_encoding(): MethodTest.EncodingTestÅngström()