Skip to content

Commit 2b8772c

Browse files
authored
Expose overload hint formatting as MethodSignatureFormatter and render Python-typed signatures (#136)
* 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. * 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<T> - 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)". * 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. * 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.
1 parent 1c136b6 commit 2b8772c

4 files changed

Lines changed: 473 additions & 148 deletions

File tree

src/embed_tests/TestFloatToIntConversion.cs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,16 +93,19 @@ public void ErrorMessage_SingleOverload_ShowsExpectedSignature()
9393
{
9494
var ex = Assert.Throws<PythonException>(() => Call("single_ctor", 5.5));
9595
StringAssert.Contains("The expected signature is:", ex.Message);
96-
StringAssert.Contains("Int32 value", ex.Message);
96+
StringAssert.Contains("value: int", ex.Message);
9797
}
9898

9999
[Test]
100100
public void ErrorMessage_MultipleOverloads_ListsCandidates()
101101
{
102102
var ex = Assert.Throws<PythonException>(() => Call("overloaded_ctor", 5.5));
103-
StringAssert.Contains("The following overloads are available:", ex.Message);
104-
// The int overload is surfaced, hinting an integer was expected.
105-
StringAssert.Contains("Int32 range", ex.Message);
103+
// The int overload is surfaced, hinting an integer was expected. The
104+
// PyObject overload is skipped (it carries no type information), which
105+
// leaves a single hinted signature here.
106+
StringAssert.Contains("The expected signature is:", ex.Message);
107+
StringAssert.Contains("range: int", ex.Message);
108+
StringAssert.DoesNotContain("volume_selector", ex.Message);
106109
}
107110

108111
// The hinted signatures use the snake_case name Python callers use, not the
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using NUnit.Framework;
4+
using Python.Runtime;
5+
6+
namespace Python.EmbeddingTest
7+
{
8+
/// <summary>
9+
/// The overload hint signatures must show the Python types a caller uses,
10+
/// following the conversions the runtime performs on arguments.
11+
/// </summary>
12+
public class TestMethodSignatureFormatter
13+
{
14+
private static string SignatureOf(string methodName, string displayName = null)
15+
{
16+
return MethodSignatureFormatter.FormatSignature(typeof(SampleTarget).GetMethod(methodName), displayName);
17+
}
18+
19+
[Test]
20+
public void FormatsPrimitivesAsPythonTypes()
21+
{
22+
Assert.AreEqual(
23+
"primitives(count: int, price: float, ratio: float, scale: float, flag: bool, name: str, letter: str)",
24+
SignatureOf(nameof(SampleTarget.Primitives)));
25+
}
26+
27+
[Test]
28+
public void FormatsTimeTypesAsDatetimeAndTimedelta()
29+
{
30+
Assert.AreEqual(
31+
"time_types(time: datetime, period: timedelta)",
32+
SignatureOf(nameof(SampleTarget.TimeTypes)));
33+
}
34+
35+
[Test]
36+
public void FormatsNullablesAsOptional()
37+
{
38+
Assert.AreEqual(
39+
"nullables(start_time: Optional[timedelta] = None, max_count: Optional[int] = None)",
40+
SignatureOf(nameof(SampleTarget.Nullables)));
41+
}
42+
43+
[Test]
44+
public void FormatsDelegatesAsCallable()
45+
{
46+
Assert.AreEqual(
47+
"delegates(selector: Callable[[datetime], int], handler: Callable[[str], None])",
48+
SignatureOf(nameof(SampleTarget.Delegates)));
49+
}
50+
51+
[Test]
52+
public void FormatsCollectionsAsListAndDict()
53+
{
54+
Assert.AreEqual(
55+
"collections(names: List[str], values: List[int], prices: List[float], lookup: Dict[str, float])",
56+
SignatureOf(nameof(SampleTarget.Collections)));
57+
}
58+
59+
[Test]
60+
public void FormatsObjectAndPyObjectAsAny()
61+
{
62+
Assert.AreEqual(
63+
"any_types(anything: Any, py_object: Any, py_list: List[Any], py_dict: Dict[Any, Any])",
64+
SignatureOf(nameof(SampleTarget.AnyTypes)));
65+
}
66+
67+
[Test]
68+
public void KeepsClrOnlyTypeNames()
69+
{
70+
Assert.AreEqual(
71+
"clr_types(address: Uri, mode: StringComparison = StringComparison.ORDINAL)",
72+
SignatureOf(nameof(SampleTarget.ClrTypes)));
73+
}
74+
75+
[Test]
76+
public void RendersConstructorsWithDisplayName()
77+
{
78+
var signature = MethodSignatureFormatter.FormatSignature(
79+
typeof(SampleTarget).GetConstructors()[0], nameof(SampleTarget));
80+
Assert.AreEqual("SampleTarget(period: timedelta, start_time: Optional[timedelta] = None)", signature);
81+
}
82+
83+
[Test]
84+
public void SkipsPyObjectOverloadsFromHints()
85+
{
86+
var hint = MethodSignatureFormatter.FormatOverloads(typeof(MixedOverloadsTarget).GetConstructors(),
87+
displayName: nameof(MixedOverloadsTarget));
88+
89+
StringAssert.Contains("The following overloads are available:", hint);
90+
StringAssert.Contains("MixedOverloadsTarget(period: timedelta)", hint);
91+
StringAssert.Contains("MixedOverloadsTarget(max_count: int)", hint);
92+
StringAssert.DoesNotContain("py_func", hint);
93+
}
94+
95+
[Test]
96+
public void ShowsPyObjectOverloadsWhenThereIsNothingElseToHint()
97+
{
98+
var hint = MethodSignatureFormatter.FormatOverloads(typeof(PyObjectOnlyTarget).GetConstructors(),
99+
displayName: nameof(PyObjectOnlyTarget));
100+
101+
StringAssert.Contains("The expected signature is:", hint);
102+
StringAssert.Contains("PyObjectOnlyTarget(py_func: Any)", hint);
103+
}
104+
105+
private class MixedOverloadsTarget
106+
{
107+
public MixedOverloadsTarget(TimeSpan period)
108+
{
109+
}
110+
111+
public MixedOverloadsTarget(int maxCount)
112+
{
113+
}
114+
115+
public MixedOverloadsTarget(PyObject pyFunc)
116+
{
117+
}
118+
}
119+
120+
private class PyObjectOnlyTarget
121+
{
122+
public PyObjectOnlyTarget(PyObject pyFunc)
123+
{
124+
}
125+
}
126+
127+
private class SampleTarget
128+
{
129+
public SampleTarget(TimeSpan period, TimeSpan? startTime = null)
130+
{
131+
}
132+
133+
public void Primitives(int count, double price, decimal ratio, float scale, bool flag, string name, char letter)
134+
{
135+
}
136+
137+
public void TimeTypes(DateTime time, TimeSpan period)
138+
{
139+
}
140+
141+
public void Nullables(TimeSpan? startTime = null, int? maxCount = null)
142+
{
143+
}
144+
145+
public void Delegates(Func<DateTime, int> selector, Action<string> handler)
146+
{
147+
}
148+
149+
public void Collections(List<string> names, IEnumerable<int> values, decimal[] prices, Dictionary<string, decimal> lookup)
150+
{
151+
}
152+
153+
public void AnyTypes(object anything, PyObject pyObject, PyList pyList, PyDict pyDict)
154+
{
155+
}
156+
157+
public void ClrTypes(Uri address, StringComparison mode = StringComparison.Ordinal)
158+
{
159+
}
160+
}
161+
}
162+
}

src/runtime/MethodBinder.cs

Lines changed: 7 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,11 +1012,11 @@ internal virtual NewReference Invoke(BorrowedReference inst, BorrowedReference a
10121012
// Use the snake_case name Python callers use, matching the hinted signatures below.
10131013
if (methodinfo != null && methodinfo.Length > 0)
10141014
{
1015-
value.Append($" for {SnakeCaseName(methodinfo[0])}");
1015+
value.Append($" for {MethodSignatureFormatter.SnakeCaseName(methodinfo[0])}");
10161016
}
10171017
else if (list.Count > 0)
10181018
{
1019-
value.Append($" for {SnakeCaseName(list[0].MethodBase)}");
1019+
value.Append($" for {MethodSignatureFormatter.SnakeCaseName(list[0].MethodBase)}");
10201020
}
10211021

10221022
value.Append(": ");
@@ -1028,7 +1028,11 @@ internal virtual NewReference Invoke(BorrowedReference inst, BorrowedReference a
10281028
var candidates = methodinfo != null && methodinfo.Length > 0
10291029
? methodinfo.Cast<MethodBase>()
10301030
: list?.Select(m => m.MethodBase);
1031-
AppendOverloads(value, candidates);
1031+
var overloads = MethodSignatureFormatter.FormatOverloads(candidates);
1032+
if (overloads.Length > 0)
1033+
{
1034+
value.Append(". ").Append(overloads);
1035+
}
10321036

10331037
Exceptions.RaiseTypeError(value.ToString());
10341038
}
@@ -1235,147 +1239,6 @@ protected static void AppendArgumentTypes(StringBuilder to, BorrowedReference ar
12351239
to.Append(')');
12361240
}
12371241

1238-
/// <summary>
1239-
/// Appends the signatures of the candidate overloads to the given error
1240-
/// message, so a failed bind hints the caller at what the method expects.
1241-
/// </summary>
1242-
private static void AppendOverloads(StringBuilder to, IEnumerable<MethodBase> methods)
1243-
{
1244-
if (methods == null)
1245-
{
1246-
return;
1247-
}
1248-
1249-
// Building this only runs on the error path; never let it throw and mask
1250-
// the original binding failure.
1251-
try
1252-
{
1253-
// Distinct signatures, preserving order. Snake-cased duplicates and
1254-
// repeated overloads collapse into a single entry.
1255-
var signatures = new List<string>();
1256-
var seen = new HashSet<string>();
1257-
foreach (var method in methods)
1258-
{
1259-
if (method == null)
1260-
{
1261-
continue;
1262-
}
1263-
var signature = FormatSignature(method);
1264-
if (seen.Add(signature))
1265-
{
1266-
signatures.Add(signature);
1267-
}
1268-
}
1269-
1270-
if (signatures.Count == 0)
1271-
{
1272-
return;
1273-
}
1274-
1275-
const int maxShown = 10;
1276-
to.Append(signatures.Count == 1
1277-
? ". The expected signature is:"
1278-
: ". The following overloads are available:");
1279-
for (var i = 0; i < signatures.Count && i < maxShown; i++)
1280-
{
1281-
to.Append("\n ").Append(signatures[i]);
1282-
}
1283-
if (signatures.Count > maxShown)
1284-
{
1285-
to.Append($"\n ... and {signatures.Count - maxShown} more");
1286-
}
1287-
}
1288-
catch
1289-
{
1290-
// Best-effort hint only.
1291-
}
1292-
}
1293-
1294-
/// <summary>
1295-
/// Formats a method/constructor as a readable signature using the snake_case
1296-
/// name Python callers use, e.g.
1297-
/// <c>range_consolidator(Int32 range, Func[IBaseData, Decimal] selector = None)</c>.
1298-
/// The constructor's special <c>.ctor</c> token is left as-is.
1299-
/// </summary>
1300-
private static string FormatSignature(MethodBase method)
1301-
{
1302-
var to = new StringBuilder();
1303-
to.Append(SnakeCaseName(method)).Append('(');
1304-
var parameters = method.GetParameters();
1305-
for (var i = 0; i < parameters.Length; i++)
1306-
{
1307-
if (i > 0)
1308-
{
1309-
to.Append(", ");
1310-
}
1311-
var parameter = parameters[i];
1312-
if (parameter.IsDefined(typeof(ParamArrayAttribute), false))
1313-
{
1314-
to.Append("params ");
1315-
}
1316-
to.Append(FormatType(parameter.ParameterType)).Append(' ').Append(parameter.Name.ToSnakeCase());
1317-
if (parameter.IsOptional)
1318-
{
1319-
to.Append(" = ").Append(FormatDefaultValue(parameter.DefaultValue));
1320-
}
1321-
}
1322-
to.Append(')');
1323-
return to.ToString();
1324-
}
1325-
1326-
/// <summary>
1327-
/// Produces a concise, readable name for a CLR type, unwrapping by-ref and
1328-
/// nullable types and rendering generics as <c>Name[Arg1, Arg2]</c>.
1329-
/// </summary>
1330-
private static string FormatType(Type type)
1331-
{
1332-
if (type.IsByRef)
1333-
{
1334-
type = type.GetElementType();
1335-
}
1336-
1337-
var underlying = Nullable.GetUnderlyingType(type);
1338-
if (underlying != null)
1339-
{
1340-
return FormatType(underlying) + "?";
1341-
}
1342-
1343-
if (type.IsGenericType)
1344-
{
1345-
var name = type.Name;
1346-
var tick = name.IndexOf('`');
1347-
if (tick >= 0)
1348-
{
1349-
name = name.Substring(0, tick);
1350-
}
1351-
var args = type.GetGenericArguments().Select(FormatType);
1352-
return $"{name}[{string.Join(", ", args)}]";
1353-
}
1354-
1355-
return type.Name;
1356-
}
1357-
1358-
/// <summary>
1359-
/// The snake_case name a Python caller uses for the given method. Constructors
1360-
/// keep their special <c>.ctor</c> token (a Python caller invokes the type).
1361-
/// </summary>
1362-
private static string SnakeCaseName(MethodBase method)
1363-
{
1364-
return method.IsConstructor ? method.Name : method.Name.ToSnakeCase();
1365-
}
1366-
1367-
private static string FormatDefaultValue(object value)
1368-
{
1369-
if (value == null || value is DBNull)
1370-
{
1371-
return "None";
1372-
}
1373-
if (value is string s)
1374-
{
1375-
return $"\"{s}\"";
1376-
}
1377-
return value.ToString();
1378-
}
13791242
}
13801243

13811244

0 commit comments

Comments
 (0)