From ad678b69b385fba0ab1eb23fca4defeb78f3ee8d Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 11 Aug 2026 11:59:59 -0400 Subject: [PATCH 1/2] Attach structured bind-failure data to the no-method-matches TypeError When no overload matches a call, the TypeError now carries the data its message is built from as attributes on the exception instance: _clr_method_name (snake_case method name), _clr_overload_signatures (tuple of formatted signatures) and _clr_overloads_hint (the rendered hint block appended to the message). Consumers such as Lean's exception interpreters can read these instead of parsing the message. The message itself is unchanged, and attribute attachment is best-effort: on any failure the plain TypeError with the same message is raised. --- src/runtime/Exceptions.cs | 19 +++++ src/runtime/MethodBinder.cs | 105 ++++++++++++++++++++++-- src/runtime/MethodSignatureFormatter.cs | 64 ++++++++++----- tests/test_method.py | 41 +++++++++ 4 files changed, 200 insertions(+), 29 deletions(-) diff --git a/src/runtime/Exceptions.cs b/src/runtime/Exceptions.cs index c3ac889ed..362e7b710 100644 --- a/src/runtime/Exceptions.cs +++ b/src/runtime/Exceptions.cs @@ -179,6 +179,25 @@ public static void SetError(BorrowedReference type, BorrowedReference exceptionO } internal const string DispatchInfoAttribute = "__dispatch_info__"; + + /// + /// Names of the attributes attached to the TypeError raised when a method call + /// cannot be bound to any overload (see MethodBinder). They carry the data the + /// message is built from, so consumers can read it without parsing the message: + /// the snake_case method name (str), the formatted overload signatures + /// (tuple of str) and the rendered overloads hint block (str) exactly as it + /// appears at the end of the message. Each attribute is only present when the + /// corresponding information is available. + /// (Internal like : 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..088f313f9 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,90 @@ internal virtual NewReference Invoke(BorrowedReference inst, BorrowedReference a return Converter.ToPython(result, returnType); } + /// + /// Raises the bind-failure TypeError with the given message, attaching the method + /// name, overload signatures and rendered overloads hint as attributes on the + /// exception instance (see the Exceptions.BindFailure*Attribute constants) so + /// consumers can read them without parsing the message. Attribute attachment is + /// 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 + { + // Normalize the freshly raised error into an exception instance, decorate + // it, and restore it as the pending error. + 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()); + } + } + } + } + } + + // Decoration must never 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 + { + 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..8e81ed782 100644 --- a/src/runtime/MethodSignatureFormatter.cs +++ b/src/runtime/MethodSignatureFormatter.cs @@ -28,14 +28,25 @@ 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, preserving order. + /// Snake-cased duplicates and repeated overloads collapse into a single entry, and + /// overloads taking PyObject parameters are skipped unless every candidate takes one + /// (see ). Never throws: signature formatting only runs + /// on error paths and must not mask the original failure. Returns an empty list when + /// there is nothing to show. + /// + 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 +56,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 +67,40 @@ 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 produced by as the hint block + /// appended to bind-failure messages: a header line followed by one signature per + /// line, capped at entries. Returns an empty string when + /// there are no signatures to show. + /// + 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() From b07e9061c4b50b7e9bd889fada7f5ad3d2257b82 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 11 Aug 2026 13:11:36 -0400 Subject: [PATCH 2/2] Tighten comments on the bind-failure attribute attachment --- src/runtime/Exceptions.cs | 13 ++++--------- src/runtime/MethodBinder.cs | 13 +++++-------- src/runtime/MethodSignatureFormatter.cs | 16 ++++++---------- 3 files changed, 15 insertions(+), 27 deletions(-) diff --git a/src/runtime/Exceptions.cs b/src/runtime/Exceptions.cs index 362e7b710..9b5e0e3d1 100644 --- a/src/runtime/Exceptions.cs +++ b/src/runtime/Exceptions.cs @@ -181,15 +181,10 @@ public static void SetError(BorrowedReference type, BorrowedReference exceptionO internal const string DispatchInfoAttribute = "__dispatch_info__"; /// - /// Names of the attributes attached to the TypeError raised when a method call - /// cannot be bound to any overload (see MethodBinder). They carry the data the - /// message is built from, so consumers can read it without parsing the message: - /// the snake_case method name (str), the formatted overload signatures - /// (tuple of str) and the rendered overloads hint block (str) exactly as it - /// appears at the end of the message. Each attribute is only present when the - /// corresponding information is available. - /// (Internal like : Initialize() resolves every - /// public static field of this class against the builtins module.) + /// 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"; diff --git a/src/runtime/MethodBinder.cs b/src/runtime/MethodBinder.cs index 088f313f9..a1dcf7022 100644 --- a/src/runtime/MethodBinder.cs +++ b/src/runtime/MethodBinder.cs @@ -1131,11 +1131,9 @@ internal virtual NewReference Invoke(BorrowedReference inst, BorrowedReference a } /// - /// Raises the bind-failure TypeError with the given message, attaching the method - /// name, overload signatures and rendered overloads hint as attributes on the - /// exception instance (see the Exceptions.BindFailure*Attribute constants) so - /// consumers can read them without parsing the message. Attribute attachment is - /// best-effort: on any failure the plain TypeError with the same message remains set. + /// 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) { @@ -1147,8 +1145,6 @@ private static void RaiseBindFailure(string message, string methodName, IReadOnl try { - // Normalize the freshly raised error into an exception instance, decorate - // it, and restore it as the pending error. Runtime.PyErr_Fetch(out var errType, out var errVal, out var errTb); try { @@ -1194,7 +1190,7 @@ private static void RaiseBindFailure(string message, string methodName, IReadOnl } } - // Decoration must never replace the bind failure with its own error. + // A failed attribute set must not replace the bind failure with its own error if (Exceptions.ErrorOccurred()) { Runtime.PyErr_Clear(); @@ -1207,6 +1203,7 @@ private static void RaiseBindFailure(string message, string methodName, IReadOnl } catch { + // The error state may have been consumed by the failed fetch/restore if (!Exceptions.ErrorOccurred()) { Exceptions.SetError(Exceptions.TypeError, message); diff --git a/src/runtime/MethodSignatureFormatter.cs b/src/runtime/MethodSignatureFormatter.cs index 8e81ed782..476be4627 100644 --- a/src/runtime/MethodSignatureFormatter.cs +++ b/src/runtime/MethodSignatureFormatter.cs @@ -33,12 +33,9 @@ public static string FormatOverloads(IEnumerable methods, int maxSho } /// - /// The distinct formatted signatures of the candidate overloads, preserving order. - /// Snake-cased duplicates and repeated overloads collapse into a single entry, and - /// overloads taking PyObject parameters are skipped unless every candidate takes one - /// (see ). Never throws: signature formatting only runs - /// on error paths and must not mask the original failure. Returns an empty list when - /// there is nothing to show. + /// 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) { @@ -77,10 +74,9 @@ internal static IReadOnlyList GetSignatures(IEnumerable meth } /// - /// Renders the signatures produced by as the hint block - /// appended to bind-failure messages: a header line followed by one signature per - /// line, capped at entries. Returns an empty string when - /// there are no signatures to show. + /// 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) {