From 141adf11a3092a9eec9379fba3d8cee557f28733 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 10 Jul 2026 15:49:52 -0400 Subject: [PATCH 1/4] Extract overload hint formatting into public MethodSignatureFormatter Move the overload-signature hint logic added in #128 (AppendOverloads, FormatSignature, FormatType, SnakeCaseName, FormatDefaultValue) out of MethodBinder into a new public MethodSignatureFormatter class so it can be reused outside the binder. Downstream consumers (e.g. Lean) can now append the same "The following overloads are available:" hint to their own user-facing error messages when they reject a PyObject argument themselves. - FormatOverloads(methods, maxShown = 10, displayName = null) returns the hint text ("The expected signature is:" / "The following overloads are available:" plus one signature per line), or an empty string. Still best-effort: it never throws. - FormatSignature(method, displayName = null) is public; the optional displayName lets constructors render as the type name instead of the special .ctor token. - MethodBinder behavior is unchanged: the "No method matches given arguments" message is byte-identical. --- src/runtime/MethodBinder.cs | 151 +-------------------- src/runtime/MethodSignatureFormatter.cs | 166 ++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 144 deletions(-) create mode 100644 src/runtime/MethodSignatureFormatter.cs diff --git a/src/runtime/MethodBinder.cs b/src/runtime/MethodBinder.cs index c27538985..cb0a40954 100644 --- a/src/runtime/MethodBinder.cs +++ b/src/runtime/MethodBinder.cs @@ -1012,11 +1012,11 @@ internal virtual NewReference Invoke(BorrowedReference inst, BorrowedReference a // Use the snake_case name Python callers use, matching the hinted signatures below. if (methodinfo != null && methodinfo.Length > 0) { - value.Append($" for {SnakeCaseName(methodinfo[0])}"); + value.Append($" for {MethodSignatureFormatter.SnakeCaseName(methodinfo[0])}"); } else if (list.Count > 0) { - value.Append($" for {SnakeCaseName(list[0].MethodBase)}"); + value.Append($" for {MethodSignatureFormatter.SnakeCaseName(list[0].MethodBase)}"); } value.Append(": "); @@ -1028,7 +1028,11 @@ internal virtual NewReference Invoke(BorrowedReference inst, BorrowedReference a var candidates = methodinfo != null && methodinfo.Length > 0 ? methodinfo.Cast() : list?.Select(m => m.MethodBase); - AppendOverloads(value, candidates); + var overloads = MethodSignatureFormatter.FormatOverloads(candidates); + if (overloads.Length > 0) + { + value.Append(". ").Append(overloads); + } Exceptions.RaiseTypeError(value.ToString()); } @@ -1235,147 +1239,6 @@ protected static void AppendArgumentTypes(StringBuilder to, BorrowedReference ar to.Append(')'); } - /// - /// Appends the signatures of the candidate overloads to the given error - /// message, so a failed bind hints the caller at what the method expects. - /// - private static void AppendOverloads(StringBuilder to, IEnumerable methods) - { - if (methods == null) - { - return; - } - - // Building this only runs on the error path; never let it throw and mask - // the original binding failure. - try - { - // 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 methods) - { - if (method == null) - { - continue; - } - var signature = FormatSignature(method); - if (seen.Add(signature)) - { - signatures.Add(signature); - } - } - - if (signatures.Count == 0) - { - return; - } - - const int maxShown = 10; - to.Append(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"); - } - } - catch - { - // Best-effort hint only. - } - } - - /// - /// Formats a method/constructor as a readable signature using the snake_case - /// name Python callers use, e.g. - /// range_consolidator(Int32 range, Func[IBaseData, Decimal] selector = None). - /// The constructor's special .ctor token is left as-is. - /// - private static string FormatSignature(MethodBase method) - { - var to = new StringBuilder(); - to.Append(SnakeCaseName(method)).Append('('); - var parameters = method.GetParameters(); - for (var i = 0; i < parameters.Length; i++) - { - if (i > 0) - { - to.Append(", "); - } - var parameter = parameters[i]; - if (parameter.IsDefined(typeof(ParamArrayAttribute), false)) - { - to.Append("params "); - } - to.Append(FormatType(parameter.ParameterType)).Append(' ').Append(parameter.Name.ToSnakeCase()); - if (parameter.IsOptional) - { - to.Append(" = ").Append(FormatDefaultValue(parameter.DefaultValue)); - } - } - to.Append(')'); - return to.ToString(); - } - - /// - /// Produces a concise, readable name for a CLR type, unwrapping by-ref and - /// nullable types and rendering generics as Name[Arg1, Arg2]. - /// - private static string FormatType(Type type) - { - if (type.IsByRef) - { - type = type.GetElementType(); - } - - var underlying = Nullable.GetUnderlyingType(type); - if (underlying != null) - { - return FormatType(underlying) + "?"; - } - - if (type.IsGenericType) - { - var name = type.Name; - var tick = name.IndexOf('`'); - if (tick >= 0) - { - name = name.Substring(0, tick); - } - var args = type.GetGenericArguments().Select(FormatType); - return $"{name}[{string.Join(", ", args)}]"; - } - - return type.Name; - } - - /// - /// The snake_case name a Python caller uses for the given method. Constructors - /// keep their special .ctor token (a Python caller invokes the type). - /// - private static string SnakeCaseName(MethodBase method) - { - return method.IsConstructor ? method.Name : method.Name.ToSnakeCase(); - } - - private static string FormatDefaultValue(object value) - { - if (value == null || value is DBNull) - { - return "None"; - } - if (value is string s) - { - return $"\"{s}\""; - } - return value.ToString(); - } } diff --git a/src/runtime/MethodSignatureFormatter.cs b/src/runtime/MethodSignatureFormatter.cs new file mode 100644 index 000000000..ee44406aa --- /dev/null +++ b/src/runtime/MethodSignatureFormatter.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; + +namespace Python.Runtime +{ + /// + /// Formats method and constructor signatures the way Python callers see them + /// (snake_case names, friendly type names). Used to hint the available overloads + /// in error messages when a call cannot be matched to any of them. + /// + public static class MethodSignatureFormatter + { + /// + /// Formats the signatures of the candidate overloads as an error message hint, + /// so the caller can see what the method expects, e.g. + /// "The following overloads are available:" followed by one signature per line. + /// Returns an empty string if there are no signatures to show. + /// + /// The candidate overloads + /// The maximum number of signatures to include + /// 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) + { + if (methods == null) + { + return string.Empty; + } + + // Building this only runs on error paths; never let it throw and mask + // the original failure. + try + { + // 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 methods) + { + if (method == null) + { + continue; + } + var signature = FormatSignature(method, displayName); + if (seen.Add(signature)) + { + signatures.Add(signature); + } + } + + 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(); + } + catch + { + // Best-effort hint only. + return string.Empty; + } + } + + /// + /// Formats a method/constructor as a readable signature using the snake_case + /// name Python callers use, e.g. + /// range_consolidator(Int32 range, Func[IBaseData, Decimal] selector = None). + /// The constructor's special .ctor token is left as-is unless + /// is provided. + /// + public static string FormatSignature(MethodBase method, string displayName = null) + { + var to = new StringBuilder(); + to.Append(displayName ?? SnakeCaseName(method)).Append('('); + var parameters = method.GetParameters(); + for (var i = 0; i < parameters.Length; i++) + { + if (i > 0) + { + to.Append(", "); + } + var parameter = parameters[i]; + if (parameter.IsDefined(typeof(ParamArrayAttribute), false)) + { + to.Append("params "); + } + to.Append(FormatType(parameter.ParameterType)).Append(' ').Append(parameter.Name.ToSnakeCase()); + if (parameter.IsOptional) + { + to.Append(" = ").Append(FormatDefaultValue(parameter.DefaultValue)); + } + } + to.Append(')'); + return to.ToString(); + } + + /// + /// The snake_case name a Python caller uses for the given method. Constructors + /// keep their special .ctor token (a Python caller invokes the type). + /// + internal static string SnakeCaseName(MethodBase method) + { + return method.IsConstructor ? method.Name : method.Name.ToSnakeCase(); + } + + /// + /// Produces a concise, readable name for a CLR type, unwrapping by-ref and + /// nullable types and rendering generics as Name[Arg1, Arg2]. + /// + private static string FormatType(Type type) + { + if (type.IsByRef) + { + type = type.GetElementType(); + } + + var underlying = Nullable.GetUnderlyingType(type); + if (underlying != null) + { + return FormatType(underlying) + "?"; + } + + if (type.IsGenericType) + { + var name = type.Name; + var tick = name.IndexOf('`'); + if (tick >= 0) + { + name = name.Substring(0, tick); + } + var args = type.GetGenericArguments().Select(FormatType); + return $"{name}[{string.Join(", ", args)}]"; + } + + return type.Name; + } + + private static string FormatDefaultValue(object value) + { + if (value == null || value is DBNull) + { + return "None"; + } + if (value is string s) + { + return $"\"{s}\""; + } + return value.ToString(); + } + } +} From 6b7b469c55a9bfcb392579efa25d89a6ca751c7b Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 10 Jul 2026 17:09:25 -0400 Subject: [PATCH 2/4] Render overload hints with Python types The hinted signatures showed C# type names (Int32, TimeSpan, Func[...]), which a Python caller has to mentally translate. Render them with the Python types the runtime actually accepts for each parameter instead: - str/int/float/bool for strings, chars and numeric primitives - datetime / timedelta for DateTime / TimeSpan - Optional[T] for Nullable - Callable[[args], ret] for delegates (None return for actions) - List[T] / Dict[K, V] for arrays, list and dictionary shapes - Any for object and PyObject (List[Any]/Dict[Any, Any] for PyList/PyDict) - CLR-only types (enums, classes) keep their Python-visible name Enum default values are rendered the way Python callers access them (e.g. StringComparison.ORDINAL) and bool defaults as True/False. Example: "range_consolidator(Int32 range, Func[IBaseData, Decimal] selector = None)" now renders as "range_consolidator(int range, Callable[[IBaseData], float] selector = None)". --- src/embed_tests/TestFloatToIntConversion.cs | 4 +- .../TestMethodSignatureFormatter.cs | 118 ++++++++++++++++++ src/runtime/MethodSignatureFormatter.cs | 117 +++++++++++++++-- 3 files changed, 230 insertions(+), 9 deletions(-) create mode 100644 src/embed_tests/TestMethodSignatureFormatter.cs diff --git a/src/embed_tests/TestFloatToIntConversion.cs b/src/embed_tests/TestFloatToIntConversion.cs index 86c77d082..6c32ab900 100644 --- a/src/embed_tests/TestFloatToIntConversion.cs +++ b/src/embed_tests/TestFloatToIntConversion.cs @@ -93,7 +93,7 @@ public void ErrorMessage_SingleOverload_ShowsExpectedSignature() { var ex = Assert.Throws(() => Call("single_ctor", 5.5)); StringAssert.Contains("The expected signature is:", ex.Message); - StringAssert.Contains("Int32 value", ex.Message); + StringAssert.Contains("int value", ex.Message); } [Test] @@ -102,7 +102,7 @@ public void ErrorMessage_MultipleOverloads_ListsCandidates() var ex = Assert.Throws(() => Call("overloaded_ctor", 5.5)); StringAssert.Contains("The following overloads are available:", ex.Message); // The int overload is surfaced, hinting an integer was expected. - StringAssert.Contains("Int32 range", ex.Message); + StringAssert.Contains("int range", ex.Message); } // The hinted signatures use the snake_case name Python callers use, not the diff --git a/src/embed_tests/TestMethodSignatureFormatter.cs b/src/embed_tests/TestMethodSignatureFormatter.cs new file mode 100644 index 000000000..ac4087ae9 --- /dev/null +++ b/src/embed_tests/TestMethodSignatureFormatter.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using NUnit.Framework; +using Python.Runtime; + +namespace Python.EmbeddingTest +{ + /// + /// The overload hint signatures must show the Python types a caller uses, + /// following the conversions the runtime performs on arguments. + /// + public class TestMethodSignatureFormatter + { + private static string SignatureOf(string methodName, string displayName = null) + { + return MethodSignatureFormatter.FormatSignature(typeof(SampleTarget).GetMethod(methodName), displayName); + } + + [Test] + public void FormatsPrimitivesAsPythonTypes() + { + Assert.AreEqual( + "primitives(int count, float price, float ratio, float scale, bool flag, str name, str letter)", + SignatureOf(nameof(SampleTarget.Primitives))); + } + + [Test] + public void FormatsTimeTypesAsDatetimeAndTimedelta() + { + Assert.AreEqual( + "time_types(datetime time, timedelta period)", + SignatureOf(nameof(SampleTarget.TimeTypes))); + } + + [Test] + public void FormatsNullablesAsOptional() + { + Assert.AreEqual( + "nullables(Optional[timedelta] start_time = None, Optional[int] max_count = None)", + SignatureOf(nameof(SampleTarget.Nullables))); + } + + [Test] + public void FormatsDelegatesAsCallable() + { + Assert.AreEqual( + "delegates(Callable[[datetime], int] selector, Callable[[str], None] handler)", + SignatureOf(nameof(SampleTarget.Delegates))); + } + + [Test] + public void FormatsCollectionsAsListAndDict() + { + Assert.AreEqual( + "collections(List[str] names, List[int] values, List[float] prices, Dict[str, float] lookup)", + SignatureOf(nameof(SampleTarget.Collections))); + } + + [Test] + public void FormatsObjectAndPyObjectAsAny() + { + Assert.AreEqual( + "any_types(Any anything, Any py_object, List[Any] py_list, Dict[Any, Any] py_dict)", + SignatureOf(nameof(SampleTarget.AnyTypes))); + } + + [Test] + public void KeepsClrOnlyTypeNames() + { + Assert.AreEqual( + "clr_types(Uri address, StringComparison mode = StringComparison.ORDINAL)", + SignatureOf(nameof(SampleTarget.ClrTypes))); + } + + [Test] + public void RendersConstructorsWithDisplayName() + { + var signature = MethodSignatureFormatter.FormatSignature( + typeof(SampleTarget).GetConstructors()[0], nameof(SampleTarget)); + Assert.AreEqual("SampleTarget(timedelta period, Optional[timedelta] start_time = None)", signature); + } + + private class SampleTarget + { + public SampleTarget(TimeSpan period, TimeSpan? startTime = null) + { + } + + public void Primitives(int count, double price, decimal ratio, float scale, bool flag, string name, char letter) + { + } + + public void TimeTypes(DateTime time, TimeSpan period) + { + } + + public void Nullables(TimeSpan? startTime = null, int? maxCount = null) + { + } + + public void Delegates(Func selector, Action handler) + { + } + + public void Collections(List names, IEnumerable values, decimal[] prices, Dictionary lookup) + { + } + + public void AnyTypes(object anything, PyObject pyObject, PyList pyList, PyDict pyDict) + { + } + + public void ClrTypes(Uri address, StringComparison mode = StringComparison.Ordinal) + { + } + } + } +} diff --git a/src/runtime/MethodSignatureFormatter.cs b/src/runtime/MethodSignatureFormatter.cs index ee44406aa..93160c9dc 100644 --- a/src/runtime/MethodSignatureFormatter.cs +++ b/src/runtime/MethodSignatureFormatter.cs @@ -8,7 +8,7 @@ namespace Python.Runtime { /// /// Formats method and constructor signatures the way Python callers see them - /// (snake_case names, friendly type names). Used to hint the available overloads + /// (snake_case names, Python type names). Used to hint the available overloads /// in error messages when a call cannot be matched to any of them. /// public static class MethodSignatureFormatter @@ -78,8 +78,8 @@ public static string FormatOverloads(IEnumerable methods, int maxSho /// /// Formats a method/constructor as a readable signature using the snake_case - /// name Python callers use, e.g. - /// range_consolidator(Int32 range, Func[IBaseData, Decimal] selector = None). + /// name and the Python types a Python caller uses, e.g. + /// range_consolidator(int range, Callable[[IBaseData], float] selector = None). /// The constructor's special .ctor token is left as-is unless /// is provided. /// @@ -119,8 +119,12 @@ internal static string SnakeCaseName(MethodBase method) } /// - /// Produces a concise, readable name for a CLR type, unwrapping by-ref and - /// nullable types and rendering generics as Name[Arg1, Arg2]. + /// Produces the Python-side name for a CLR type, following the conversions the + /// runtime performs on arguments: primitives map to their Python equivalents + /// (str, int, float, bool, datetime, timedelta), Nullable to Optional, delegates + /// to Callable, list/dictionary shapes to List/Dict and PyObject/object to Any. + /// CLR types without a Python equivalent keep their name, with generics rendered + /// as Name[Arg1, Arg2]. /// private static string FormatType(Type type) { @@ -132,18 +136,108 @@ private static string FormatType(Type type) var underlying = Nullable.GetUnderlyingType(type); if (underlying != null) { - return FormatType(underlying) + "?"; + return $"Optional[{FormatType(underlying)}]"; + } + + if (type == typeof(void)) + { + return "None"; + } + if (type == typeof(TimeSpan)) + { + return "timedelta"; + } + if (type == typeof(object)) + { + return "Any"; + } + if (typeof(Type).IsAssignableFrom(type)) + { + return "type"; + } + + // pythonnet wrapper parameters accept any Python object of the matching shape + if (type == typeof(PyList)) + { + return "List[Any]"; + } + if (type == typeof(PyDict)) + { + return "Dict[Any, Any]"; + } + if (typeof(PyObject).IsAssignableFrom(type)) + { + return "Any"; + } + + if (type.IsArray) + { + return $"List[{FormatType(type.GetElementType())}]"; + } + + if (typeof(Delegate).IsAssignableFrom(type) && !type.ContainsGenericParameters) + { + var invoke = type.GetMethod("Invoke"); + if (invoke != null) + { + var args = string.Join(", ", invoke.GetParameters().Select(p => FormatType(p.ParameterType))); + return $"Callable[[{args}], {FormatType(invoke.ReturnType)}]"; + } + } + + // Enums have an integer type code but keep their Python-visible name + if (!type.IsEnum) + { + switch (Type.GetTypeCode(type)) + { + case TypeCode.Boolean: + return "bool"; + case TypeCode.Char: + case TypeCode.String: + return "str"; + case TypeCode.SByte: + case TypeCode.Byte: + case TypeCode.Int16: + case TypeCode.UInt16: + case TypeCode.Int32: + case TypeCode.UInt32: + case TypeCode.Int64: + case TypeCode.UInt64: + return "int"; + case TypeCode.Single: + case TypeCode.Double: + case TypeCode.Decimal: + return "float"; + case TypeCode.DateTime: + return "datetime"; + } } if (type.IsGenericType) { + var definition = type.GetGenericTypeDefinition(); + var genericArguments = type.GetGenericArguments(); + + // list and dictionary shapes the runtime converts from Python lists/dicts + if (definition == typeof(List<>) || definition == typeof(IList<>) || + definition == typeof(IEnumerable<>) || definition == typeof(ICollection<>) || + definition == typeof(IReadOnlyList<>) || definition == typeof(IReadOnlyCollection<>)) + { + return $"List[{FormatType(genericArguments[0])}]"; + } + if (definition == typeof(Dictionary<,>) || definition == typeof(IDictionary<,>) || + definition == typeof(IReadOnlyDictionary<,>) || definition == typeof(KeyValuePair<,>)) + { + return $"Dict[{FormatType(genericArguments[0])}, {FormatType(genericArguments[1])}]"; + } + var name = type.Name; var tick = name.IndexOf('`'); if (tick >= 0) { name = name.Substring(0, tick); } - var args = type.GetGenericArguments().Select(FormatType); + var args = genericArguments.Select(FormatType); return $"{name}[{string.Join(", ", args)}]"; } @@ -160,6 +254,15 @@ private static string FormatDefaultValue(object value) { return $"\"{s}\""; } + if (value is bool b) + { + return b ? "True" : "False"; + } + if (value is Enum e) + { + // Render enum defaults the way Python callers access them, e.g. Resolution.DAILY + return $"{e.GetType().Name}.{e.ToString().ToSnakeCase(constant: true)}"; + } return value.ToString(); } } From ace60d20a69d7ef845f0694a61a46984ee66c11d Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 10 Jul 2026 17:24:14 -0400 Subject: [PATCH 3/4] Render overload hint parameters as Python annotations Python signatures annotate the name, not prefix the type: an argument rendered as "int arg_name" is actually "arg_name: int". Render the hinted signatures accordingly, e.g.: range_consolidator(range: int, selector: Callable[[IBaseData], float] = None) params arrays are rendered in Python variadic form, annotated with the element type: *values: int. --- src/embed_tests/TestFloatToIntConversion.cs | 4 ++-- src/embed_tests/TestMethodSignatureFormatter.cs | 16 ++++++++-------- src/runtime/MethodSignatureFormatter.cs | 15 ++++++++++----- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/embed_tests/TestFloatToIntConversion.cs b/src/embed_tests/TestFloatToIntConversion.cs index 6c32ab900..05e7d5838 100644 --- a/src/embed_tests/TestFloatToIntConversion.cs +++ b/src/embed_tests/TestFloatToIntConversion.cs @@ -93,7 +93,7 @@ public void ErrorMessage_SingleOverload_ShowsExpectedSignature() { var ex = Assert.Throws(() => Call("single_ctor", 5.5)); StringAssert.Contains("The expected signature is:", ex.Message); - StringAssert.Contains("int value", ex.Message); + StringAssert.Contains("value: int", ex.Message); } [Test] @@ -102,7 +102,7 @@ public void ErrorMessage_MultipleOverloads_ListsCandidates() var ex = Assert.Throws(() => Call("overloaded_ctor", 5.5)); StringAssert.Contains("The following overloads are available:", ex.Message); // The int overload is surfaced, hinting an integer was expected. - StringAssert.Contains("int range", ex.Message); + StringAssert.Contains("range: int", ex.Message); } // The hinted signatures use the snake_case name Python callers use, not the diff --git a/src/embed_tests/TestMethodSignatureFormatter.cs b/src/embed_tests/TestMethodSignatureFormatter.cs index ac4087ae9..2831b9d5f 100644 --- a/src/embed_tests/TestMethodSignatureFormatter.cs +++ b/src/embed_tests/TestMethodSignatureFormatter.cs @@ -20,7 +20,7 @@ private static string SignatureOf(string methodName, string displayName = null) public void FormatsPrimitivesAsPythonTypes() { Assert.AreEqual( - "primitives(int count, float price, float ratio, float scale, bool flag, str name, str letter)", + "primitives(count: int, price: float, ratio: float, scale: float, flag: bool, name: str, letter: str)", SignatureOf(nameof(SampleTarget.Primitives))); } @@ -28,7 +28,7 @@ public void FormatsPrimitivesAsPythonTypes() public void FormatsTimeTypesAsDatetimeAndTimedelta() { Assert.AreEqual( - "time_types(datetime time, timedelta period)", + "time_types(time: datetime, period: timedelta)", SignatureOf(nameof(SampleTarget.TimeTypes))); } @@ -36,7 +36,7 @@ public void FormatsTimeTypesAsDatetimeAndTimedelta() public void FormatsNullablesAsOptional() { Assert.AreEqual( - "nullables(Optional[timedelta] start_time = None, Optional[int] max_count = None)", + "nullables(start_time: Optional[timedelta] = None, max_count: Optional[int] = None)", SignatureOf(nameof(SampleTarget.Nullables))); } @@ -44,7 +44,7 @@ public void FormatsNullablesAsOptional() public void FormatsDelegatesAsCallable() { Assert.AreEqual( - "delegates(Callable[[datetime], int] selector, Callable[[str], None] handler)", + "delegates(selector: Callable[[datetime], int], handler: Callable[[str], None])", SignatureOf(nameof(SampleTarget.Delegates))); } @@ -52,7 +52,7 @@ public void FormatsDelegatesAsCallable() public void FormatsCollectionsAsListAndDict() { Assert.AreEqual( - "collections(List[str] names, List[int] values, List[float] prices, Dict[str, float] lookup)", + "collections(names: List[str], values: List[int], prices: List[float], lookup: Dict[str, float])", SignatureOf(nameof(SampleTarget.Collections))); } @@ -60,7 +60,7 @@ public void FormatsCollectionsAsListAndDict() public void FormatsObjectAndPyObjectAsAny() { Assert.AreEqual( - "any_types(Any anything, Any py_object, List[Any] py_list, Dict[Any, Any] py_dict)", + "any_types(anything: Any, py_object: Any, py_list: List[Any], py_dict: Dict[Any, Any])", SignatureOf(nameof(SampleTarget.AnyTypes))); } @@ -68,7 +68,7 @@ public void FormatsObjectAndPyObjectAsAny() public void KeepsClrOnlyTypeNames() { Assert.AreEqual( - "clr_types(Uri address, StringComparison mode = StringComparison.ORDINAL)", + "clr_types(address: Uri, mode: StringComparison = StringComparison.ORDINAL)", SignatureOf(nameof(SampleTarget.ClrTypes))); } @@ -77,7 +77,7 @@ public void RendersConstructorsWithDisplayName() { var signature = MethodSignatureFormatter.FormatSignature( typeof(SampleTarget).GetConstructors()[0], nameof(SampleTarget)); - Assert.AreEqual("SampleTarget(timedelta period, Optional[timedelta] start_time = None)", signature); + Assert.AreEqual("SampleTarget(period: timedelta, start_time: Optional[timedelta] = None)", signature); } private class SampleTarget diff --git a/src/runtime/MethodSignatureFormatter.cs b/src/runtime/MethodSignatureFormatter.cs index 93160c9dc..4c91a4ce5 100644 --- a/src/runtime/MethodSignatureFormatter.cs +++ b/src/runtime/MethodSignatureFormatter.cs @@ -77,9 +77,9 @@ public static string FormatOverloads(IEnumerable methods, int maxSho } /// - /// Formats a method/constructor as a readable signature using the snake_case - /// name and the Python types a Python caller uses, e.g. - /// range_consolidator(int range, Callable[[IBaseData], float] selector = None). + /// Formats a method/constructor as a Python signature: snake_case name and + /// parameters annotated with the Python types a Python caller uses, e.g. + /// range_consolidator(range: int, selector: Callable[[IBaseData], float] = None). /// The constructor's special .ctor token is left as-is unless /// is provided. /// @@ -97,9 +97,14 @@ public static string FormatSignature(MethodBase method, string displayName = nul var parameter = parameters[i]; if (parameter.IsDefined(typeof(ParamArrayAttribute), false)) { - to.Append("params "); + // Python variadic form; annotate with the element type + var elementType = parameter.ParameterType.IsArray + ? parameter.ParameterType.GetElementType() + : parameter.ParameterType; + to.Append('*').Append(parameter.Name.ToSnakeCase()).Append(": ").Append(FormatType(elementType)); + continue; } - to.Append(FormatType(parameter.ParameterType)).Append(' ').Append(parameter.Name.ToSnakeCase()); + to.Append(parameter.Name.ToSnakeCase()).Append(": ").Append(FormatType(parameter.ParameterType)); if (parameter.IsOptional) { to.Append(" = ").Append(FormatDefaultValue(parameter.DefaultValue)); From ff0fcb6a1cbf5f46c9efb19d1b602000e8ba0370 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 10 Jul 2026 17:33:36 -0400 Subject: [PATCH 4/4] Skip PyObject overloads from the hinted signatures PyObject parameters accept any Python object and render as Any, so hinting them carries no type information: they are typically the very overloads that just rejected the value. Skip them in FormatOverloads so consumers do not have to filter them out themselves. If every candidate takes a PyObject, they are shown anyway rather than producing no hint at all. --- src/embed_tests/TestFloatToIntConversion.cs | 7 ++- .../TestMethodSignatureFormatter.cs | 44 +++++++++++++++++++ src/runtime/MethodSignatureFormatter.cs | 33 +++++++++++--- 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/src/embed_tests/TestFloatToIntConversion.cs b/src/embed_tests/TestFloatToIntConversion.cs index 05e7d5838..b2802e7f7 100644 --- a/src/embed_tests/TestFloatToIntConversion.cs +++ b/src/embed_tests/TestFloatToIntConversion.cs @@ -100,9 +100,12 @@ public void ErrorMessage_SingleOverload_ShowsExpectedSignature() public void ErrorMessage_MultipleOverloads_ListsCandidates() { var ex = Assert.Throws(() => Call("overloaded_ctor", 5.5)); - StringAssert.Contains("The following overloads are available:", ex.Message); - // The int overload is surfaced, hinting an integer was expected. + // The int overload is surfaced, hinting an integer was expected. The + // PyObject overload is skipped (it carries no type information), which + // leaves a single hinted signature here. + StringAssert.Contains("The expected signature is:", ex.Message); StringAssert.Contains("range: int", ex.Message); + StringAssert.DoesNotContain("volume_selector", ex.Message); } // The hinted signatures use the snake_case name Python callers use, not the diff --git a/src/embed_tests/TestMethodSignatureFormatter.cs b/src/embed_tests/TestMethodSignatureFormatter.cs index 2831b9d5f..d647f9928 100644 --- a/src/embed_tests/TestMethodSignatureFormatter.cs +++ b/src/embed_tests/TestMethodSignatureFormatter.cs @@ -80,6 +80,50 @@ public void RendersConstructorsWithDisplayName() Assert.AreEqual("SampleTarget(period: timedelta, start_time: Optional[timedelta] = None)", signature); } + [Test] + public void SkipsPyObjectOverloadsFromHints() + { + var hint = MethodSignatureFormatter.FormatOverloads(typeof(MixedOverloadsTarget).GetConstructors(), + displayName: nameof(MixedOverloadsTarget)); + + StringAssert.Contains("The following overloads are available:", hint); + StringAssert.Contains("MixedOverloadsTarget(period: timedelta)", hint); + StringAssert.Contains("MixedOverloadsTarget(max_count: int)", hint); + StringAssert.DoesNotContain("py_func", hint); + } + + [Test] + public void ShowsPyObjectOverloadsWhenThereIsNothingElseToHint() + { + var hint = MethodSignatureFormatter.FormatOverloads(typeof(PyObjectOnlyTarget).GetConstructors(), + displayName: nameof(PyObjectOnlyTarget)); + + StringAssert.Contains("The expected signature is:", hint); + StringAssert.Contains("PyObjectOnlyTarget(py_func: Any)", hint); + } + + private class MixedOverloadsTarget + { + public MixedOverloadsTarget(TimeSpan period) + { + } + + public MixedOverloadsTarget(int maxCount) + { + } + + public MixedOverloadsTarget(PyObject pyFunc) + { + } + } + + private class PyObjectOnlyTarget + { + public PyObjectOnlyTarget(PyObject pyFunc) + { + } + } + private class SampleTarget { public SampleTarget(TimeSpan period, TimeSpan? startTime = null) diff --git a/src/runtime/MethodSignatureFormatter.cs b/src/runtime/MethodSignatureFormatter.cs index 4c91a4ce5..a382ee172 100644 --- a/src/runtime/MethodSignatureFormatter.cs +++ b/src/runtime/MethodSignatureFormatter.cs @@ -17,6 +17,10 @@ public static class MethodSignatureFormatter /// Formats the signatures of the candidate overloads as an error message hint, /// so the caller can see what the method expects, e.g. /// "The following overloads are available:" followed by one signature per line. + /// Overloads taking PyObject parameters are skipped: they accept any Python + /// object and carry no type information (they are typically the overloads that + /// just rejected the value). If every candidate takes a PyObject, they are shown + /// anyway rather than producing no hint at all. /// Returns an empty string if there are no signatures to show. /// /// The candidate overloads @@ -34,16 +38,19 @@ public static string FormatOverloads(IEnumerable methods, int maxSho // the original failure. try { + var candidates = methods.Where(method => method != null).ToList(); + var withoutPyObject = candidates.Where(method => !TakesPyObject(method)).ToList(); + if (withoutPyObject.Count > 0) + { + 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 methods) + foreach (var method in candidates) { - if (method == null) - { - continue; - } var signature = FormatSignature(method, displayName); if (seen.Add(signature)) { @@ -123,6 +130,22 @@ internal static string SnakeCaseName(MethodBase method) return method.IsConstructor ? method.Name : method.Name.ToSnakeCase(); } + /// + /// Determines whether any of the method's parameters is a PyObject + /// + private static bool TakesPyObject(MethodBase method) + { + return method.GetParameters().Any(parameter => + { + var type = parameter.ParameterType; + if (type.IsByRef) + { + type = type.GetElementType(); + } + return typeof(PyObject).IsAssignableFrom(type); + }); + } + /// /// Produces the Python-side name for a CLR type, following the conversions the /// runtime performs on arguments: primitives map to their Python equivalents