Skip to content

Commit 0d2cdd2

Browse files
committed
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.
1 parent c080337 commit 0d2cdd2

3 files changed

Lines changed: 188 additions & 0 deletions

File tree

src/runtime/MethodBinder.cs

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,6 +1016,14 @@ internal virtual NewReference Invoke(BorrowedReference inst, BorrowedReference a
10161016
// If we already have an exception pending, don't create a new one
10171017
if (!Exceptions.ErrorOccurred())
10181018
{
1019+
// A keyword argument whose name no candidate overload accepts gets the
1020+
// Python-native "unexpected keyword argument" error: the generic no-match
1021+
// message below does not echo kwargs, leaving the actual mistake invisible.
1022+
if (TryRaiseUnexpectedKeywordArgumentError(kw, info, methodinfo))
1023+
{
1024+
return default;
1025+
}
1026+
10191027
var value = new StringBuilder("No method matches given arguments");
10201028
// Use the snake_case name Python callers use, matching the hinted signatures below.
10211029
if (methodinfo != null && methodinfo.Length > 0)
@@ -1123,6 +1131,134 @@ internal virtual NewReference Invoke(BorrowedReference inst, BorrowedReference a
11231131
return Converter.ToPython(result, returnType);
11241132
}
11251133

1134+
/// <summary>
1135+
/// When a bind failure involves a keyword argument whose name no candidate overload
1136+
/// accepts, raises the Python-style "got an unexpected keyword argument" TypeError
1137+
/// (with a "Did you mean" hint when a similarly-named parameter exists) and returns
1138+
/// true. Returns false when every kwarg name is accepted by at least one overload,
1139+
/// so the generic no-match error is raised instead.
1140+
/// </summary>
1141+
private bool TryRaiseUnexpectedKeywordArgumentError(BorrowedReference kw, MethodBase info, MethodInfo[] methodinfo)
1142+
{
1143+
var kwCount = kw == null ? 0 : (int)Runtime.PyDict_Size(kw);
1144+
if (kwCount <= 0)
1145+
{
1146+
return false;
1147+
}
1148+
1149+
// The same candidate set Bind considered: parameter names are snake_case for
1150+
// snake_case-registered methods and original for the original ones, matching
1151+
// the names the caller can actually use.
1152+
var methods = info == null
1153+
? GetMethods()
1154+
: new List<MethodInformation>(1) { new MethodInformation(info, true) };
1155+
var parameterNames = new HashSet<string>(StringComparer.Ordinal);
1156+
foreach (var method in methods)
1157+
{
1158+
foreach (var parameterName in method.ParameterNames)
1159+
{
1160+
parameterNames.Add(parameterName);
1161+
}
1162+
}
1163+
1164+
// Report the first unknown kwarg in call order, like CPython does.
1165+
string unexpectedName = null;
1166+
using (var keyList = Runtime.PyDict_Keys(kw))
1167+
{
1168+
for (var i = 0; i < kwCount && unexpectedName == null; i++)
1169+
{
1170+
var name = Runtime.GetManagedString(Runtime.PyList_GetItem(keyList.Borrow(), i));
1171+
if (name != null && !parameterNames.Contains(name))
1172+
{
1173+
unexpectedName = name;
1174+
}
1175+
}
1176+
}
1177+
1178+
if (unexpectedName == null)
1179+
{
1180+
return false;
1181+
}
1182+
1183+
string methodName = null;
1184+
if (methodinfo != null && methodinfo.Length > 0)
1185+
{
1186+
methodName = MethodSignatureFormatter.SnakeCaseName(methodinfo[0]);
1187+
}
1188+
else if (list.Count > 0)
1189+
{
1190+
methodName = MethodSignatureFormatter.SnakeCaseName(list[0].MethodBase);
1191+
}
1192+
if (string.IsNullOrEmpty(methodName))
1193+
{
1194+
return false;
1195+
}
1196+
1197+
var message = $"{methodName}() got an unexpected keyword argument '{unexpectedName}'";
1198+
var suggestion = ClosestParameterName(unexpectedName, parameterNames);
1199+
if (suggestion != null)
1200+
{
1201+
message += $". Did you mean '{suggestion}'?";
1202+
}
1203+
1204+
Exceptions.RaiseTypeError(message);
1205+
return true;
1206+
}
1207+
1208+
/// <summary>
1209+
/// The candidate parameter name closest to the unexpected kwarg name, or null when
1210+
/// none is similar enough to suggest. A candidate is considered when it is within
1211+
/// a small edit distance of the name, or when one contains the other (e.g. 'as_tag'
1212+
/// suggests 'tag'); containment requires 3+ characters so tiny names don't match.
1213+
/// </summary>
1214+
private static string ClosestParameterName(string name, HashSet<string> parameterNames)
1215+
{
1216+
const int MinContainmentLength = 3;
1217+
var threshold = Math.Max(2, name.Length / 3);
1218+
string best = null;
1219+
var bestDistance = int.MaxValue;
1220+
foreach (var candidate in parameterNames)
1221+
{
1222+
var distance = KeywordEditDistance(name, candidate);
1223+
var related = distance <= threshold
1224+
|| (candidate.Length >= MinContainmentLength && name.Length >= MinContainmentLength
1225+
&& (candidate.IndexOf(name, StringComparison.OrdinalIgnoreCase) >= 0
1226+
|| name.IndexOf(candidate, StringComparison.OrdinalIgnoreCase) >= 0));
1227+
if (related && (distance < bestDistance
1228+
|| (distance == bestDistance && string.CompareOrdinal(candidate, best) < 0)))
1229+
{
1230+
bestDistance = distance;
1231+
best = candidate;
1232+
}
1233+
}
1234+
return best;
1235+
}
1236+
1237+
// Case-insensitive Levenshtein distance, local to keyword suggestions.
1238+
private static int KeywordEditDistance(string a, string b)
1239+
{
1240+
a = a.ToLowerInvariant();
1241+
b = b.ToLowerInvariant();
1242+
if (a.Length == 0) return b.Length;
1243+
if (b.Length == 0) return a.Length;
1244+
1245+
var prev = new int[b.Length + 1];
1246+
var curr = new int[b.Length + 1];
1247+
for (var j = 0; j <= b.Length; j++) prev[j] = j;
1248+
1249+
for (var i = 1; i <= a.Length; i++)
1250+
{
1251+
curr[0] = i;
1252+
for (var j = 1; j <= b.Length; j++)
1253+
{
1254+
var cost = a[i - 1] == b[j - 1] ? 0 : 1;
1255+
curr[j] = Math.Min(Math.Min(curr[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost);
1256+
}
1257+
(prev, curr) = (curr, prev);
1258+
}
1259+
return prev[b.Length];
1260+
}
1261+
11261262
/// <summary>
11271263
/// Utility class to store the information about a <see cref="MethodBase"/>
11281264
/// </summary>

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: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,6 +1104,53 @@ 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+
# A kwarg no overload accepts raises the Python-style error naming the kwarg,
1109+
# with a did-you-mean hint when a similarly-named parameter exists.
1110+
with pytest.raises(TypeError) as excinfo:
1111+
MethodTest.order_like_method("SPY", 10, as_tag="EmergencyFlatten")
1112+
message = str(excinfo.value)
1113+
assert "order_like_method() got an unexpected keyword argument 'as_tag'" in message
1114+
assert "Did you mean 'tag'?" in message
1115+
1116+
# Same behavior when calling through the original PascalCase name; parameter
1117+
# names are the original ones there.
1118+
with pytest.raises(TypeError) as excinfo:
1119+
MethodTest.OrderLikeMethod("SPY", 10, asTag="EmergencyFlatten")
1120+
message = str(excinfo.value)
1121+
assert "order_like_method() got an unexpected keyword argument 'asTag'" in message
1122+
assert "Did you mean 'tag'?" in message
1123+
1124+
1125+
def test_unexpected_keyword_argument_without_suggestion():
1126+
# No parameter is remotely similar: the kwarg is still named, but no hint is added.
1127+
with pytest.raises(TypeError) as excinfo:
1128+
MethodTest.order_like_method("SPY", 10, completely_unrelated_name=1)
1129+
message = str(excinfo.value)
1130+
assert "order_like_method() got an unexpected keyword argument " \
1131+
"'completely_unrelated_name'" in message
1132+
assert "Did you mean" not in message
1133+
1134+
1135+
def test_unexpected_keyword_argument_reports_first_in_call_order():
1136+
with pytest.raises(TypeError) as excinfo:
1137+
MethodTest.order_like_method("SPY", 10, first_bogus=1, second_bogus=2)
1138+
assert "got an unexpected keyword argument 'first_bogus'" in str(excinfo.value)
1139+
1140+
1141+
def test_valid_keyword_arguments_still_bind():
1142+
res = MethodTest.order_like_method("SPY", 10, asynchronous=True, tag="mytag")
1143+
assert res == "SPY:10:True:mytag"
1144+
1145+
1146+
def test_valid_keyword_argument_names_keep_no_match_message():
1147+
# All kwarg names are real parameters, but the call still cannot bind ('d' is
1148+
# supplied both positionally and by name): the classic no-method-matches
1149+
# message must be preserved for this case.
1150+
with pytest.raises(TypeError) as excinfo:
1151+
MethodTest.DefaultParams(1, 2, 3, 4, d=5)
1152+
assert "No method matches given arguments for default_params" in str(excinfo.value)
1153+
11071154
def test_optional_params():
11081155
res = MethodTest.OptionalParams(1, 2, 3, 4)
11091156
assert res == "1234"

0 commit comments

Comments
 (0)