Skip to content

Commit 91d6829

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

5 files changed

Lines changed: 405 additions & 1 deletion

File tree

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

src/runtime/MethodBinder.cs

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

1045+
// Point at the argument that failed against the nearest overload so
1046+
// the caller doesn't have to diff the signatures by eye. Appended
1047+
// after the overloads block: consumers that extract the hint from
1048+
// that marker onwards keep this line too.
1049+
var mismatch = DiagnoseClosestOverloadMismatch(candidates, args, kw);
1050+
if (mismatch.Length > 0)
1051+
{
1052+
value.Append('\n').Append(mismatch);
1053+
}
1054+
10451055
Exceptions.RaiseTypeError(value.ToString());
10461056
}
10471057

@@ -1216,6 +1226,260 @@ public MatchedMethod(int kwargsMatched, object?[] margs, int outs, MethodInforma
12161226
}
12171227
}
12181228

1229+
/// <summary>
1230+
/// Builds a one-line diagnosis of the first argument that fails to match the
1231+
/// nearest candidate overload (the one with the most leading convertible
1232+
/// arguments), e.g. "Argument mismatch: argument 3 ('asynchronous') expected
1233+
/// bool, got str." Returns an empty string when there is nothing conclusive to
1234+
/// report (e.g. a pure arity mismatch). Only runs on the bind-failure path; it
1235+
/// never throws and never leaves a Python error pending.
1236+
/// </summary>
1237+
private static string DiagnoseClosestOverloadMismatch(IEnumerable<MethodBase> candidates, BorrowedReference args, BorrowedReference kw)
1238+
{
1239+
try
1240+
{
1241+
if (candidates == null)
1242+
{
1243+
return string.Empty;
1244+
}
1245+
1246+
var pyArgCount = args == null ? 0 : (int)Runtime.PyTuple_Size(args);
1247+
1248+
// Snapshot the keyword arguments with strong references so they stay
1249+
// valid while candidates are probed.
1250+
List<KeyValuePair<string, PyObject>> kwargs = null;
1251+
if (kw != null && Runtime.PyDict_Size(kw) > 0)
1252+
{
1253+
kwargs = new List<KeyValuePair<string, PyObject>>();
1254+
using var keyList = Runtime.PyDict_Keys(kw);
1255+
using var valueList = Runtime.PyDict_Values(kw);
1256+
var kwCount = (int)Runtime.PyList_Size(keyList.Borrow());
1257+
for (var i = 0; i < kwCount; i++)
1258+
{
1259+
var name = Runtime.GetManagedString(Runtime.PyList_GetItem(keyList.Borrow(), i));
1260+
if (name != null)
1261+
{
1262+
kwargs.Add(new KeyValuePair<string, PyObject>(
1263+
name, new PyObject(Runtime.PyList_GetItem(valueList.Borrow(), i))));
1264+
}
1265+
}
1266+
}
1267+
1268+
var bestScore = -1;
1269+
var bestMismatchIndex = -1;
1270+
ParameterInfo bestMismatchParameter = null;
1271+
string bestKwargName = null;
1272+
PyObject bestKwargValue = null;
1273+
1274+
foreach (var method in candidates)
1275+
{
1276+
if (method == null || OperatorMethod.IsOperatorMethod(method))
1277+
{
1278+
continue;
1279+
}
1280+
1281+
var pi = method.GetParameters();
1282+
var paramsArrayIndex = pi.Length > 0 && Attribute.IsDefined(pi[pi.Length - 1], typeof(ParamArrayAttribute))
1283+
? pi.Length - 1
1284+
: -1;
1285+
1286+
var score = 0;
1287+
var mismatchIndex = -1;
1288+
var limit = Math.Min(pyArgCount, pi.Length);
1289+
for (var i = 0; i < limit; i++)
1290+
{
1291+
if (i == paramsArrayIndex)
1292+
{
1293+
// Remaining arguments feed the params array; probing its
1294+
// element conversions here would be guesswork, count them
1295+
// as matched.
1296+
score = limit;
1297+
break;
1298+
}
1299+
1300+
var op = Runtime.PyTuple_GetItem(args, i);
1301+
if (op == null)
1302+
{
1303+
Exceptions.Clear();
1304+
break;
1305+
}
1306+
1307+
if (!ArgumentMatchesParameter(op, pi[i]))
1308+
{
1309+
mismatchIndex = i;
1310+
break;
1311+
}
1312+
score++;
1313+
}
1314+
1315+
string kwargName = null;
1316+
PyObject kwargValue = null;
1317+
ParameterInfo kwargParameter = null;
1318+
if (mismatchIndex == -1 && kwargs != null)
1319+
{
1320+
foreach (var pair in kwargs)
1321+
{
1322+
var parameter = pi.FirstOrDefault(p => p.Name == pair.Key || p.Name.ToSnakeCase() == pair.Key);
1323+
if (parameter == null)
1324+
{
1325+
// Not a parameter of this overload; flagging unknown
1326+
// keyword names is out of scope here.
1327+
continue;
1328+
}
1329+
1330+
if (ArgumentMatchesParameter(pair.Value.Reference, parameter))
1331+
{
1332+
score++;
1333+
}
1334+
else
1335+
{
1336+
kwargName = pair.Key;
1337+
kwargValue = pair.Value;
1338+
kwargParameter = parameter;
1339+
break;
1340+
}
1341+
}
1342+
}
1343+
1344+
if (mismatchIndex == -1 && kwargName == null)
1345+
{
1346+
// Everything given matched: the failure was arity or keyword
1347+
// related, nothing conclusive to pinpoint for this candidate.
1348+
continue;
1349+
}
1350+
1351+
if (score > bestScore)
1352+
{
1353+
bestScore = score;
1354+
bestMismatchIndex = mismatchIndex;
1355+
bestKwargName = kwargName;
1356+
bestKwargValue = kwargValue;
1357+
bestMismatchParameter = mismatchIndex != -1 ? pi[mismatchIndex] : kwargParameter;
1358+
}
1359+
}
1360+
1361+
if (bestMismatchParameter == null)
1362+
{
1363+
return string.Empty;
1364+
}
1365+
1366+
var expected = MethodSignatureFormatter.FormatType(bestMismatchParameter.ParameterType);
1367+
var parameterName = bestMismatchParameter.Name.ToSnakeCase();
1368+
if (bestKwargName != null)
1369+
{
1370+
return $"Argument mismatch: keyword argument '{bestKwargName}' expected {expected}, got {GetPythonTypeName(bestKwargValue.Reference)}.";
1371+
}
1372+
1373+
var mismatchedArg = Runtime.PyTuple_GetItem(args, bestMismatchIndex);
1374+
var got = mismatchedArg == null ? Util.BadStr : GetPythonTypeName(mismatchedArg);
1375+
return $"Argument mismatch: argument {bestMismatchIndex + 1} ('{parameterName}') expected {expected}, got {got}.";
1376+
}
1377+
catch
1378+
{
1379+
// Best-effort hint only; never mask the original failure.
1380+
return string.Empty;
1381+
}
1382+
finally
1383+
{
1384+
// Probing conversions may have left a Python error set; the caller is
1385+
// about to raise the real TypeError.
1386+
Exceptions.Clear();
1387+
}
1388+
}
1389+
1390+
/// <summary>
1391+
/// Mirror of the per-argument acceptance rules the binder applies when matching
1392+
/// an overload (type alias equality, matching type codes, lossless numeric
1393+
/// conversions, implicit operators), used to find the first mismatching
1394+
/// argument for the bind-failure diagnosis. Lenient where probing is unreliable
1395+
/// (by-ref, generic and untyped parameters) so it under-reports rather than
1396+
/// blames the wrong argument.
1397+
/// </summary>
1398+
private static bool ArgumentMatchesParameter(BorrowedReference op, ParameterInfo parameter)
1399+
{
1400+
var parameterType = parameter.ParameterType;
1401+
if (parameterType.IsByRef || parameterType.ContainsGenericParameters || parameterType == typeof(object))
1402+
{
1403+
return true;
1404+
}
1405+
1406+
Type clrtype = null;
1407+
using (var pyoptype = Runtime.PyObject_Type(op))
1408+
{
1409+
Exceptions.Clear();
1410+
if (!pyoptype.IsNull())
1411+
{
1412+
clrtype = Converter.GetTypeByAlias(pyoptype.Borrow());
1413+
}
1414+
}
1415+
1416+
if (clrtype == null)
1417+
{
1418+
// Not a primitive-aliased Python value (e.g. a wrapped CLR object):
1419+
// probe the conversion itself.
1420+
var converted = Converter.ToManaged(op, parameterType, out _, false);
1421+
Exceptions.Clear();
1422+
return converted;
1423+
}
1424+
1425+
if (parameterType == clrtype)
1426+
{
1427+
return true;
1428+
}
1429+
1430+
var pytype = Converter.GetPythonTypeByAlias(parameterType);
1431+
using (var pyoptype = Runtime.PyObject_Type(op))
1432+
{
1433+
Exceptions.Clear();
1434+
if (!pyoptype.IsNull() && pytype == pyoptype.Borrow())
1435+
{
1436+
return true;
1437+
}
1438+
}
1439+
1440+
var underlyingType = Nullable.GetUnderlyingType(parameterType) ?? parameterType;
1441+
if (Type.GetTypeCode(underlyingType) == Type.GetTypeCode(clrtype))
1442+
{
1443+
return true;
1444+
}
1445+
1446+
if (underlyingType == typeof(decimal) || underlyingType == typeof(double)
1447+
|| (Runtime.PyFloat_Check(op) && Type.GetTypeCode(underlyingType).IsInteger() && !underlyingType.IsEnum))
1448+
{
1449+
var converted = Converter.ToManaged(op, parameterType, out _, false);
1450+
Exceptions.Clear();
1451+
if (converted)
1452+
{
1453+
return true;
1454+
}
1455+
}
1456+
1457+
var opImplicit = parameterType.GetMethod("op_Implicit", new[] { clrtype });
1458+
return opImplicit != null && opImplicit.ReturnType == parameterType;
1459+
}
1460+
1461+
/// <summary>
1462+
/// The Python type name of a value (e.g. "str", "float64"), for error messages.
1463+
/// </summary>
1464+
private static string GetPythonTypeName(BorrowedReference op)
1465+
{
1466+
using var pyType = Runtime.PyObject_Type(op);
1467+
if (!pyType.IsNull())
1468+
{
1469+
using var name = Runtime.PyObject_GetAttrString(pyType.Borrow(), "__name__");
1470+
if (!name.IsNull())
1471+
{
1472+
var managed = Runtime.GetManagedString(name.Borrow());
1473+
if (!string.IsNullOrEmpty(managed))
1474+
{
1475+
return managed;
1476+
}
1477+
}
1478+
}
1479+
Exceptions.Clear();
1480+
return Util.BadStr;
1481+
}
1482+
12191483
protected static void AppendArgumentTypes(StringBuilder to, BorrowedReference args)
12201484
{
12211485
long argCount = Runtime.PyTuple_Size(args);

0 commit comments

Comments
 (0)