Skip to content

Commit 3464917

Browse files
authored
Name the unexpected keyword argument in the bind-failure TypeError (#144)
* Raise a proper unexpected-keyword-argument TypeError on bind failure When a method call fails to bind and one of the supplied keyword arguments matches no parameter of any candidate overload, the generic 'No method matches given arguments' message did not mention the keyword argument at all (only positional argument types are echoed), leaving the actual mistake invisible, e.g.: market_order(symbol, -10, as_tag="EmergencyFlatten") -> No method matches given arguments for market_order: (<class 'Symbol'>, <class 'int'>). The following overloads ... Now such calls raise the Python-style error instead, naming the offending kwarg and suggesting the closest parameter name when one exists: market_order() got an unexpected keyword argument 'as_tag'. Did you mean 'tag'? When every kwarg name is valid for some overload but binding still fails, the existing no-method-matches message is preserved. * Tighten comments in unexpected-keyword-argument error path * Share the Levenshtein distance helper between ClassBase and MethodBinder Moves ClassBase's private LevenshteinDistance implementation verbatim to Util.LevenshteinDistance and uses it from both call sites, removing the duplicate introduced for keyword-argument suggestions. * Extend the no-match error with the unexpected keyword argument instead of replacing it * Never let bind-failure message construction throw
1 parent cc51794 commit 3464917

5 files changed

Lines changed: 198 additions & 46 deletions

File tree

src/runtime/MethodBinder.cs

Lines changed: 119 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,29 +1017,48 @@ internal virtual NewReference Invoke(BorrowedReference inst, BorrowedReference a
10171017
if (!Exceptions.ErrorOccurred())
10181018
{
10191019
var value = new StringBuilder("No method matches given arguments");
1020-
// Use the snake_case name Python callers use, matching the hinted signatures below.
1021-
if (methodinfo != null && methodinfo.Length > 0)
1020+
try
10221021
{
1023-
value.Append($" for {MethodSignatureFormatter.SnakeCaseName(methodinfo[0])}");
1024-
}
1025-
else if (list.Count > 0)
1026-
{
1027-
value.Append($" for {MethodSignatureFormatter.SnakeCaseName(list[0].MethodBase)}");
1028-
}
1022+
// Use the snake_case name Python callers use, matching the hinted signatures below.
1023+
if (methodinfo != null && methodinfo.Length > 0)
1024+
{
1025+
value.Append($" for {MethodSignatureFormatter.SnakeCaseName(methodinfo[0])}");
1026+
}
1027+
else if (list.Count > 0)
1028+
{
1029+
value.Append($" for {MethodSignatureFormatter.SnakeCaseName(list[0].MethodBase)}");
1030+
}
10291031

1030-
value.Append(": ");
1031-
AppendArgumentTypes(to: value, args);
1032-
1033-
// List the candidate overloads so the caller can see what was
1034-
// expected (e.g. that an int overload exists when a float was
1035-
// passed). Applies to every "no match" case, not just numeric ones.
1036-
var candidates = methodinfo != null && methodinfo.Length > 0
1037-
? methodinfo.Cast<MethodBase>()
1038-
: list?.Select(m => m.MethodBase);
1039-
var overloads = MethodSignatureFormatter.FormatOverloads(candidates);
1040-
if (overloads.Length > 0)
1032+
value.Append(": ");
1033+
AppendArgumentTypes(to: value, args);
1034+
1035+
// The argument types echo above covers positional args only; name the first
1036+
// unknown kwarg (if any) so a misspelled keyword argument is visible.
1037+
AppendUnexpectedKeywordArgument(value, kw, info);
1038+
1039+
// List the candidate overloads so the caller can see what was
1040+
// expected (e.g. that an int overload exists when a float was
1041+
// passed). Applies to every "no match" case, not just numeric ones.
1042+
var candidates = methodinfo != null && methodinfo.Length > 0
1043+
? methodinfo.Cast<MethodBase>()
1044+
: list?.Select(m => m.MethodBase);
1045+
var overloads = MethodSignatureFormatter.FormatOverloads(candidates);
1046+
if (overloads.Length > 0)
1047+
{
1048+
// The kwarg hint may already end the sentence with a question mark.
1049+
if (value[value.Length - 1] != '?')
1050+
{
1051+
value.Append('.');
1052+
}
1053+
value.Append(' ').Append(overloads);
1054+
}
1055+
}
1056+
catch
10411057
{
1042-
value.Append(". ").Append(overloads);
1058+
// The details above are best-effort diagnostics over arbitrary caller
1059+
// input; an exception here would escape the tp_call slot into CPython
1060+
// and mask the bind failure. Raise with whatever was appended so far.
1061+
Exceptions.Clear();
10431062
}
10441063

10451064
// After the overloads block: consumers that extract the hint from
@@ -1131,6 +1150,86 @@ internal virtual NewReference Invoke(BorrowedReference inst, BorrowedReference a
11311150
return Converter.ToPython(result, returnType);
11321151
}
11331152

1153+
/// <summary>
1154+
/// Appends "Got an unexpected keyword argument" to the no-match message when a kwarg
1155+
/// name is accepted by no candidate overload, with a "Did you mean" suggestion when a
1156+
/// similar parameter name exists. Appends nothing when every kwarg name is valid.
1157+
/// </summary>
1158+
private void AppendUnexpectedKeywordArgument(StringBuilder to, BorrowedReference kw, MethodBase info)
1159+
{
1160+
var kwCount = kw == null ? 0 : (int)Runtime.PyDict_Size(kw);
1161+
if (kwCount <= 0)
1162+
{
1163+
return;
1164+
}
1165+
1166+
// Same candidate set Bind considered; ParameterNames are already in the caller's convention.
1167+
var methods = info == null
1168+
? GetMethods()
1169+
: new List<MethodInformation>(1) { new MethodInformation(info, true) };
1170+
var parameterNames = new HashSet<string>(StringComparer.Ordinal);
1171+
foreach (var method in methods)
1172+
{
1173+
foreach (var parameterName in method.ParameterNames)
1174+
{
1175+
parameterNames.Add(parameterName);
1176+
}
1177+
}
1178+
1179+
// Report the first unknown kwarg in call order, like CPython does.
1180+
string unexpectedName = null;
1181+
using (var keyList = Runtime.PyDict_Keys(kw))
1182+
{
1183+
for (var i = 0; i < kwCount && unexpectedName == null; i++)
1184+
{
1185+
var name = Runtime.GetManagedString(Runtime.PyList_GetItem(keyList.Borrow(), i));
1186+
if (name != null && !parameterNames.Contains(name))
1187+
{
1188+
unexpectedName = name;
1189+
}
1190+
}
1191+
}
1192+
1193+
if (unexpectedName == null)
1194+
{
1195+
return;
1196+
}
1197+
1198+
to.Append($". Got an unexpected keyword argument '{unexpectedName}'");
1199+
var suggestion = ClosestParameterName(unexpectedName, parameterNames);
1200+
if (suggestion != null)
1201+
{
1202+
to.Append($". Did you mean '{suggestion}'?");
1203+
}
1204+
}
1205+
1206+
/// <summary>
1207+
/// Closest parameter name to suggest, or null: small edit distance, or containment
1208+
/// between names of 3+ characters (e.g. 'as_tag' suggests 'tag').
1209+
/// </summary>
1210+
private static string ClosestParameterName(string name, HashSet<string> parameterNames)
1211+
{
1212+
const int MinContainmentLength = 3;
1213+
var threshold = Math.Max(2, name.Length / 3);
1214+
string best = null;
1215+
var bestDistance = int.MaxValue;
1216+
foreach (var candidate in parameterNames)
1217+
{
1218+
var distance = Util.LevenshteinDistance(name, candidate);
1219+
var related = distance <= threshold
1220+
|| (candidate.Length >= MinContainmentLength && name.Length >= MinContainmentLength
1221+
&& (candidate.IndexOf(name, StringComparison.OrdinalIgnoreCase) >= 0
1222+
|| name.IndexOf(candidate, StringComparison.OrdinalIgnoreCase) >= 0));
1223+
if (related && (distance < bestDistance
1224+
|| (distance == bestDistance && string.CompareOrdinal(candidate, best) < 0)))
1225+
{
1226+
bestDistance = distance;
1227+
best = candidate;
1228+
}
1229+
}
1230+
return best;
1231+
}
1232+
11341233
/// <summary>
11351234
/// Utility class to store the information about a <see cref="MethodBase"/>
11361235
/// </summary>

src/runtime/Types/ClassBase.cs

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -845,7 +845,7 @@ private static string ComputeSimilarMemberNames(Type type, string name)
845845
var scored = new List<(string Name, int Distance, SuggestionKind Kind)>();
846846
foreach (var candidate in GetCandidateMemberNames(type))
847847
{
848-
var distance = LevenshteinDistance(name, candidate.Key);
848+
var distance = Util.LevenshteinDistance(name, candidate.Key);
849849
var related = distance <= threshold
850850
|| candidate.Key.IndexOf(name, StringComparison.OrdinalIgnoreCase) >= 0
851851
|| name.IndexOf(candidate.Key, StringComparison.OrdinalIgnoreCase) >= 0;
@@ -895,30 +895,5 @@ private static (string Name, SuggestionKind Kind) ToSnakeCaseMemberName(MemberIn
895895
};
896896
}
897897

898-
private static int LevenshteinDistance(string a, string b)
899-
{
900-
a = a.ToLowerInvariant();
901-
b = b.ToLowerInvariant();
902-
var n = a.Length;
903-
var m = b.Length;
904-
if (n == 0) return m;
905-
if (m == 0) return n;
906-
907-
var prev = new int[m + 1];
908-
var curr = new int[m + 1];
909-
for (var j = 0; j <= m; j++) prev[j] = j;
910-
911-
for (var i = 1; i <= n; i++)
912-
{
913-
curr[0] = i;
914-
for (var j = 1; j <= m; j++)
915-
{
916-
var cost = a[i - 1] == b[j - 1] ? 0 : 1;
917-
curr[j] = Math.Min(Math.Min(curr[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost);
918-
}
919-
(prev, curr) = (curr, prev);
920-
}
921-
return prev[m];
922-
}
923898
}
924899
}

src/runtime/Util/Util.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,5 +336,32 @@ public static bool IsInteger(this TypeCode typeCode)
336336
return false;
337337
}
338338
}
339+
340+
// Case-insensitive Levenshtein distance.
341+
internal static int LevenshteinDistance(string a, string b)
342+
{
343+
a = a.ToLowerInvariant();
344+
b = b.ToLowerInvariant();
345+
var n = a.Length;
346+
var m = b.Length;
347+
if (n == 0) return m;
348+
if (m == 0) return n;
349+
350+
var prev = new int[m + 1];
351+
var curr = new int[m + 1];
352+
for (var j = 0; j <= m; j++) prev[j] = j;
353+
354+
for (var i = 1; i <= n; i++)
355+
{
356+
curr[0] = i;
357+
for (var j = 1; j <= m; j++)
358+
{
359+
var cost = a[i - 1] == b[j - 1] ? 0 : 1;
360+
curr[j] = Math.Min(Math.Min(curr[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost);
361+
}
362+
(prev, curr) = (curr, prev);
363+
}
364+
return prev[m];
365+
}
339366
}
340367
}

src/testing/methodtest.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,11 @@ public static string DefaultParamsWithOverloading(int a = 5, int b = 6, int c =
709709
return $"{a}{b}{c}{d}XXX";
710710
}
711711

712+
public static string OrderLikeMethod(string symbol, decimal quantity, bool asynchronous = false, string tag = "", object orderProperties = null)
713+
{
714+
return string.Format("{0}:{1}:{2}:{3}", symbol, quantity, asynchronous, tag);
715+
}
716+
712717
public static string ParamsArrayOverloaded(int i = 1)
713718
{
714719
return "without params-array";

tests/test_method.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,6 +1104,52 @@ def test_default_params():
11041104
with pytest.raises(TypeError):
11051105
MethodTest.DefaultParams(1,2,3,4,5)
11061106

1107+
def test_unexpected_keyword_argument_with_suggestion():
1108+
with pytest.raises(TypeError) as excinfo:
1109+
MethodTest.order_like_method("SPY", 10, as_tag="EmergencyFlatten")
1110+
message = str(excinfo.value)
1111+
assert "No method matches given arguments for order_like_method" in message
1112+
assert "Got an unexpected keyword argument 'as_tag'" in message
1113+
assert "Did you mean 'tag'?" in message
1114+
1115+
# PascalCase call path: parameter names are the original ones.
1116+
with pytest.raises(TypeError) as excinfo:
1117+
MethodTest.OrderLikeMethod("SPY", 10, asTag="EmergencyFlatten")
1118+
message = str(excinfo.value)
1119+
assert "No method matches given arguments for order_like_method" in message
1120+
assert "Got an unexpected keyword argument 'asTag'" in message
1121+
assert "Did you mean 'tag'?" in message
1122+
1123+
1124+
def test_unexpected_keyword_argument_without_suggestion():
1125+
with pytest.raises(TypeError) as excinfo:
1126+
MethodTest.order_like_method("SPY", 10, completely_unrelated_name=1)
1127+
message = str(excinfo.value)
1128+
assert "No method matches given arguments for order_like_method" in message
1129+
assert "Got an unexpected keyword argument " \
1130+
"'completely_unrelated_name'" in message
1131+
assert "Did you mean" not in message
1132+
1133+
1134+
def test_unexpected_keyword_argument_reports_first_in_call_order():
1135+
with pytest.raises(TypeError) as excinfo:
1136+
MethodTest.order_like_method("SPY", 10, first_bogus=1, second_bogus=2)
1137+
assert "Got an unexpected keyword argument 'first_bogus'" in str(excinfo.value)
1138+
1139+
1140+
def test_valid_keyword_arguments_still_bind():
1141+
res = MethodTest.order_like_method("SPY", 10, asynchronous=True, tag="mytag")
1142+
assert res == "SPY:10:True:mytag"
1143+
1144+
1145+
def test_valid_keyword_argument_names_keep_no_match_message():
1146+
# 'd' is supplied both positionally and by name: valid names, unbindable call.
1147+
with pytest.raises(TypeError) as excinfo:
1148+
MethodTest.DefaultParams(1, 2, 3, 4, d=5)
1149+
message = str(excinfo.value)
1150+
assert "No method matches given arguments for default_params" in message
1151+
assert "unexpected keyword argument" not in message
1152+
11071153
def test_optional_params():
11081154
res = MethodTest.OptionalParams(1, 2, 3, 4)
11091155
assert res == "1234"

0 commit comments

Comments
 (0)