Skip to content

Commit ad87b8f

Browse files
authored
Pinpoint the first mismatched argument in bind-failure TypeErrors (#145)
* Pinpoint the first mismatched argument in bind-failure TypeErrors When no overload matches a call, the TypeError now appends a diagnosis of the first argument that fails to match the nearest overload (the one with the most leading convertible arguments), e.g.: Argument mismatch: argument 3 ('asynchronous') expected bool, got str. Keyword arguments whose values cannot convert to the matching parameter are diagnosed too. The line is appended after the overloads hint so consumers that extract the hint from its marker onwards keep it. * Reuse Runtime.PyObject_GetTypeName in the bind-failure diagnosis Drops the duplicated type-name helper in favor of the existing runtime one, and tightens the new comments.
1 parent c080337 commit ad87b8f

5 files changed

Lines changed: 366 additions & 1 deletion

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
using NUnit.Framework;
2+
using Python.Runtime;
3+
4+
namespace Python.EmbeddingTest
5+
{
6+
/// <summary>
7+
/// The bind-failure TypeError must pinpoint the first argument that fails to
8+
/// match the nearest overload.
9+
/// </summary>
10+
public class TestBindFailureDiagnosis
11+
{
12+
public class OrdersTarget
13+
{
14+
public string PlaceOrder(string symbol, decimal quantity, bool asynchronous = false, string tag = "", int depth = 0) => "decimal";
15+
public string PlaceOrder(string symbol, int quantity, bool asynchronous = false, string tag = "", int depth = 0) => "int";
16+
}
17+
18+
public class SingleOverloadTarget
19+
{
20+
public int Compute(int periods) => periods;
21+
}
22+
23+
[OneTimeSetUp]
24+
public void SetUp()
25+
{
26+
PythonEngine.Initialize();
27+
}
28+
29+
[OneTimeTearDown]
30+
public void Dispose()
31+
{
32+
PythonEngine.Shutdown();
33+
}
34+
35+
private static string TypeErrorMessageOf(string call)
36+
{
37+
using var _ = Py.GIL();
38+
var module = PyModule.FromString("TestBindFailureDiagnosis_" + TestContext.CurrentContext.Test.Name, $@"
39+
from clr import AddReference
40+
AddReference(""Python.EmbeddingTest"")
41+
AddReference(""System"")
42+
43+
from Python.EmbeddingTest import *
44+
45+
def get_error():
46+
target = TestBindFailureDiagnosis.OrdersTarget()
47+
single = TestBindFailureDiagnosis.SingleOverloadTarget()
48+
try:
49+
{call}
50+
except TypeError as e:
51+
return str(e)
52+
return None
53+
");
54+
using var result = module.GetAttr("get_error").Invoke();
55+
Assert.IsFalse(result.IsNone(), "expected the call to raise a TypeError");
56+
return result.As<string>();
57+
}
58+
59+
[Test]
60+
public void PinpointsFirstMismatchedPositionalArgument()
61+
{
62+
var message = TypeErrorMessageOf("target.place_order('SPY', -10, 'exit signal')");
63+
64+
Assert.That(message, Does.StartWith("No method matches given arguments for place_order: "));
65+
Assert.That(message, Does.Contain("The following overloads are available:"));
66+
Assert.That(message, Does.Contain("Argument mismatch: argument 3 ('asynchronous') expected bool, got str."));
67+
}
68+
69+
[Test]
70+
public void PinpointsMismatchedKeywordArgument()
71+
{
72+
var message = TypeErrorMessageOf("target.place_order('SPY', 10, tag=5)");
73+
74+
Assert.That(message, Does.Contain("Argument mismatch: keyword argument 'tag' expected str, got int."));
75+
}
76+
77+
[Test]
78+
public void PinpointsMismatchOnSingleOverloadMethods()
79+
{
80+
var message = TypeErrorMessageOf("single.compute('abc')");
81+
82+
Assert.That(message, Does.Contain("The expected signature is:"));
83+
Assert.That(message, Does.Contain("Argument mismatch: argument 1 ('periods') expected int, got str."));
84+
}
85+
86+
[Test]
87+
public void SkipsDiagnosisWhenAllGivenArgumentsMatch()
88+
{
89+
// Pure arity failure: no mismatched argument to single out.
90+
var message = TypeErrorMessageOf("single.compute(1, 2)");
91+
92+
Assert.That(message, Does.Contain("No method matches given arguments for compute"));
93+
Assert.That(message, Does.Not.Contain("Argument mismatch:"));
94+
}
95+
96+
[Test]
97+
public void DiagnosisSurvivesTheOverloadsHintExtraction()
98+
{
99+
// Lean keeps the message from the overloads marker onwards; the diagnosis
100+
// must be inside that region to reach users.
101+
var message = TypeErrorMessageOf("target.place_order('SPY', -10, 'exit signal')");
102+
103+
var hintStart = message.IndexOf("The following overloads are available:");
104+
Assert.GreaterOrEqual(hintStart, 0);
105+
var hint = message.Substring(hintStart);
106+
Assert.That(hint, Does.Contain("Argument mismatch: argument 3 ('asynchronous') expected bool, got str."));
107+
}
108+
}
109+
}

src/runtime/MethodBinder.cs

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1042,6 +1042,14 @@ internal virtual NewReference Invoke(BorrowedReference inst, BorrowedReference a
10421042
value.Append(". ").Append(overloads);
10431043
}
10441044

1045+
// After the overloads block: consumers that extract the hint from
1046+
// its marker onwards must keep this line too.
1047+
var mismatch = DiagnoseClosestOverloadMismatch(candidates, args, kw);
1048+
if (mismatch.Length > 0)
1049+
{
1050+
value.Append('\n').Append(mismatch);
1051+
}
1052+
10451053
Exceptions.RaiseTypeError(value.ToString());
10461054
}
10471055

@@ -1216,6 +1224,228 @@ public MatchedMethod(int kwargsMatched, object?[] margs, int outs, MethodInforma
12161224
}
12171225
}
12181226

1227+
/// <summary>
1228+
/// One-line diagnosis of the first argument failing to match the nearest
1229+
/// overload (most leading convertible arguments), e.g. "Argument mismatch:
1230+
/// argument 3 ('asynchronous') expected bool, got str." Empty when nothing
1231+
/// conclusive (e.g. pure arity mismatch). Never throws, never leaves a
1232+
/// Python error pending.
1233+
/// </summary>
1234+
private static string DiagnoseClosestOverloadMismatch(IEnumerable<MethodBase> candidates, BorrowedReference args, BorrowedReference kw)
1235+
{
1236+
try
1237+
{
1238+
if (candidates == null)
1239+
{
1240+
return string.Empty;
1241+
}
1242+
1243+
var pyArgCount = args == null ? 0 : (int)Runtime.PyTuple_Size(args);
1244+
1245+
// Strong references: the values must outlive the candidate probing.
1246+
List<KeyValuePair<string, PyObject>> kwargs = null;
1247+
if (kw != null && Runtime.PyDict_Size(kw) > 0)
1248+
{
1249+
kwargs = new List<KeyValuePair<string, PyObject>>();
1250+
using var keyList = Runtime.PyDict_Keys(kw);
1251+
using var valueList = Runtime.PyDict_Values(kw);
1252+
var kwCount = (int)Runtime.PyList_Size(keyList.Borrow());
1253+
for (var i = 0; i < kwCount; i++)
1254+
{
1255+
var name = Runtime.GetManagedString(Runtime.PyList_GetItem(keyList.Borrow(), i));
1256+
if (name != null)
1257+
{
1258+
kwargs.Add(new KeyValuePair<string, PyObject>(
1259+
name, new PyObject(Runtime.PyList_GetItem(valueList.Borrow(), i))));
1260+
}
1261+
}
1262+
}
1263+
1264+
var bestScore = -1;
1265+
var bestMismatchIndex = -1;
1266+
ParameterInfo bestMismatchParameter = null;
1267+
string bestKwargName = null;
1268+
PyObject bestKwargValue = null;
1269+
1270+
foreach (var method in candidates)
1271+
{
1272+
if (method == null || OperatorMethod.IsOperatorMethod(method))
1273+
{
1274+
continue;
1275+
}
1276+
1277+
var pi = method.GetParameters();
1278+
var paramsArrayIndex = pi.Length > 0 && Attribute.IsDefined(pi[pi.Length - 1], typeof(ParamArrayAttribute))
1279+
? pi.Length - 1
1280+
: -1;
1281+
1282+
var score = 0;
1283+
var mismatchIndex = -1;
1284+
var limit = Math.Min(pyArgCount, pi.Length);
1285+
for (var i = 0; i < limit; i++)
1286+
{
1287+
if (i == paramsArrayIndex)
1288+
{
1289+
// Params-array element conversions aren't probed; count the tail as matched.
1290+
score = limit;
1291+
break;
1292+
}
1293+
1294+
var op = Runtime.PyTuple_GetItem(args, i);
1295+
if (op == null)
1296+
{
1297+
Exceptions.Clear();
1298+
break;
1299+
}
1300+
1301+
if (!ArgumentMatchesParameter(op, pi[i]))
1302+
{
1303+
mismatchIndex = i;
1304+
break;
1305+
}
1306+
score++;
1307+
}
1308+
1309+
string kwargName = null;
1310+
PyObject kwargValue = null;
1311+
ParameterInfo kwargParameter = null;
1312+
if (mismatchIndex == -1 && kwargs != null)
1313+
{
1314+
foreach (var pair in kwargs)
1315+
{
1316+
var parameter = pi.FirstOrDefault(p => p.Name == pair.Key || p.Name.ToSnakeCase() == pair.Key);
1317+
if (parameter == null)
1318+
{
1319+
// Unknown keyword names are not this diagnosis' job.
1320+
continue;
1321+
}
1322+
1323+
if (ArgumentMatchesParameter(pair.Value.Reference, parameter))
1324+
{
1325+
score++;
1326+
}
1327+
else
1328+
{
1329+
kwargName = pair.Key;
1330+
kwargValue = pair.Value;
1331+
kwargParameter = parameter;
1332+
break;
1333+
}
1334+
}
1335+
}
1336+
1337+
if (mismatchIndex == -1 && kwargName == null)
1338+
{
1339+
// Everything given matched: nothing to pinpoint for this candidate.
1340+
continue;
1341+
}
1342+
1343+
if (score > bestScore)
1344+
{
1345+
bestScore = score;
1346+
bestMismatchIndex = mismatchIndex;
1347+
bestKwargName = kwargName;
1348+
bestKwargValue = kwargValue;
1349+
bestMismatchParameter = mismatchIndex != -1 ? pi[mismatchIndex] : kwargParameter;
1350+
}
1351+
}
1352+
1353+
if (bestMismatchParameter == null)
1354+
{
1355+
return string.Empty;
1356+
}
1357+
1358+
var expected = MethodSignatureFormatter.FormatType(bestMismatchParameter.ParameterType);
1359+
var parameterName = bestMismatchParameter.Name.ToSnakeCase();
1360+
if (bestKwargName != null)
1361+
{
1362+
return $"Argument mismatch: keyword argument '{bestKwargName}' expected {expected}, got {Runtime.PyObject_GetTypeName(bestKwargValue.Reference)}.";
1363+
}
1364+
1365+
var mismatchedArg = Runtime.PyTuple_GetItem(args, bestMismatchIndex);
1366+
var got = mismatchedArg == null ? Util.BadStr : Runtime.PyObject_GetTypeName(mismatchedArg);
1367+
return $"Argument mismatch: argument {bestMismatchIndex + 1} ('{parameterName}') expected {expected}, got {got}.";
1368+
}
1369+
catch
1370+
{
1371+
// Best-effort hint only; never mask the original failure.
1372+
return string.Empty;
1373+
}
1374+
finally
1375+
{
1376+
// Conversion probes may have left a Python error set.
1377+
Exceptions.Clear();
1378+
}
1379+
}
1380+
1381+
/// <summary>
1382+
/// Mirror of the binder's per-argument acceptance rules, used to find the first
1383+
/// mismatching argument. Lenient where probing is unreliable (by-ref, generic
1384+
/// and untyped parameters) so it under-reports rather than blames the wrong
1385+
/// argument.
1386+
/// </summary>
1387+
private static bool ArgumentMatchesParameter(BorrowedReference op, ParameterInfo parameter)
1388+
{
1389+
var parameterType = parameter.ParameterType;
1390+
if (parameterType.IsByRef || parameterType.ContainsGenericParameters || parameterType == typeof(object))
1391+
{
1392+
return true;
1393+
}
1394+
1395+
Type clrtype = null;
1396+
using (var pyoptype = Runtime.PyObject_Type(op))
1397+
{
1398+
Exceptions.Clear();
1399+
if (!pyoptype.IsNull())
1400+
{
1401+
clrtype = Converter.GetTypeByAlias(pyoptype.Borrow());
1402+
}
1403+
}
1404+
1405+
if (clrtype == null)
1406+
{
1407+
// Not a primitive-aliased value (e.g. a wrapped CLR object): probe the conversion.
1408+
var converted = Converter.ToManaged(op, parameterType, out _, false);
1409+
Exceptions.Clear();
1410+
return converted;
1411+
}
1412+
1413+
if (parameterType == clrtype)
1414+
{
1415+
return true;
1416+
}
1417+
1418+
var pytype = Converter.GetPythonTypeByAlias(parameterType);
1419+
using (var pyoptype = Runtime.PyObject_Type(op))
1420+
{
1421+
Exceptions.Clear();
1422+
if (!pyoptype.IsNull() && pytype == pyoptype.Borrow())
1423+
{
1424+
return true;
1425+
}
1426+
}
1427+
1428+
var underlyingType = Nullable.GetUnderlyingType(parameterType) ?? parameterType;
1429+
if (Type.GetTypeCode(underlyingType) == Type.GetTypeCode(clrtype))
1430+
{
1431+
return true;
1432+
}
1433+
1434+
if (underlyingType == typeof(decimal) || underlyingType == typeof(double)
1435+
|| (Runtime.PyFloat_Check(op) && Type.GetTypeCode(underlyingType).IsInteger() && !underlyingType.IsEnum))
1436+
{
1437+
var converted = Converter.ToManaged(op, parameterType, out _, false);
1438+
Exceptions.Clear();
1439+
if (converted)
1440+
{
1441+
return true;
1442+
}
1443+
}
1444+
1445+
var opImplicit = parameterType.GetMethod("op_Implicit", new[] { clrtype });
1446+
return opImplicit != null && opImplicit.ReturnType == parameterType;
1447+
}
1448+
12191449
protected static void AppendArgumentTypes(StringBuilder to, BorrowedReference args)
12201450
{
12211451
long argCount = Runtime.PyTuple_Size(args);

src/runtime/MethodSignatureFormatter.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ private static bool TakesPyObject(MethodBase method)
154154
/// CLR types without a Python equivalent keep their name, with generics rendered
155155
/// as <c>Name[Arg1, Arg2]</c>.
156156
/// </summary>
157-
private static string FormatType(Type type)
157+
internal static string FormatType(Type type)
158158
{
159159
if (type.IsByRef)
160160
{

src/testing/methodtest.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,16 @@ public static void PointerArray(int*[] array)
729729
{
730730

731731
}
732+
733+
public static string BindDiagnosisMethod(string symbol, double quantity, bool asynchronous = false, string tag = "")
734+
{
735+
return "double";
736+
}
737+
738+
public static string BindDiagnosisMethod(string symbol, int quantity, bool asynchronous = false, string tag = "")
739+
{
740+
return "int";
741+
}
732742
}
733743

734744

tests/test_method.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1261,3 +1261,19 @@ def test_method_encoding():
12611261
def test_method_with_pointer_array_argument():
12621262
with pytest.raises(TypeError):
12631263
MethodTest.PointerArray([0])
1264+
1265+
1266+
def test_bind_failure_pinpoints_mismatched_argument():
1267+
with pytest.raises(TypeError) as excinfo:
1268+
MethodTest.bind_diagnosis_method("SPY", -10, "exit signal")
1269+
message = str(excinfo.value)
1270+
assert message.startswith("No method matches given arguments for bind_diagnosis_method: ")
1271+
assert "The following overloads are available:" in message
1272+
assert "Argument mismatch: argument 3 ('asynchronous') expected bool, got str." in message
1273+
1274+
1275+
def test_bind_failure_pinpoints_mismatched_keyword_argument():
1276+
with pytest.raises(TypeError) as excinfo:
1277+
MethodTest.bind_diagnosis_method("SPY", 10, tag=5)
1278+
message = str(excinfo.value)
1279+
assert "Argument mismatch: keyword argument 'tag' expected str, got int." in message

0 commit comments

Comments
 (0)