Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions src/runtime/MethodBinder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1052,6 +1052,14 @@ internal virtual NewReference Invoke(BorrowedReference inst, BorrowedReference a
}
value.Append(' ').Append(overloads);
}

// After the overloads block: consumers that extract the hint from
// its marker onwards must keep this line too.
var mismatch = DiagnoseClosestOverloadMismatch(candidates, args, kw);
if (mismatch.Length > 0)
{
value.Append('\n').Append(mismatch);
}
}
catch
{
Expand All @@ -1061,14 +1069,6 @@ internal virtual NewReference Invoke(BorrowedReference inst, BorrowedReference a
Exceptions.Clear();
}

// After the overloads block: consumers that extract the hint from
// its marker onwards must keep this line too.
var mismatch = DiagnoseClosestOverloadMismatch(candidates, args, kw);
if (mismatch.Length > 0)
{
value.Append('\n').Append(mismatch);
}

Exceptions.RaiseTypeError(value.ToString());
}

Expand Down
47 changes: 37 additions & 10 deletions src/runtime/Types/ClassBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ private enum SuggestionKind
// getattr(self, "_optional", None) on a .NET-derived object, or a mistyped enum value).
// Memoize the fully-built " Did you mean: ...?" hint (empty when there is nothing to
// suggest) per (type, missing-name) so repeats are a dictionary lookup instead of an
// O(members) reflection + Levenshtein scan on every miss.
// O(members) reflection + similarity scan on every miss.
private static readonly ConcurrentDictionary<(Type Type, string Name), string> _suggestionCache = new();

internal ClassBase(Type tp)
Expand Down Expand Up @@ -837,21 +837,31 @@ private static Dictionary<string, SuggestionKind> GetCandidateMemberNames(Type t
// Builds the " Did you mean: 'x', 'y'?" hint for a missing attribute, or an empty
// string when no member is similar enough to suggest. The result is cached in
// _suggestionCache, so this runs at most once per (type, missing-name).
//
// Jaro-Winkler (prefix-favoring) keeps suffix-extended targets that an edit-distance
// cutoff rejects (InteractiveBrokers -> INTERACTIVE_BROKERS_BROKERAGE); gated
// containment covers fragment lookups outside its match window ('cash' -> 'set_cash').
private static string ComputeSimilarMemberNames(Type type, string name)
{
const int MaxSuggestions = 5;
var threshold = Math.Max(2, name.Length / 3);
// In evaluation over real member sets, intended targets scored >= 0.90 and noise <= 0.85.
const double SimilarityThreshold = 0.87;

var scored = new List<(string Name, int Distance, SuggestionKind Kind)>();
var scored = new List<(string Name, double Score, SuggestionKind Kind)>();
foreach (var candidate in GetCandidateMemberNames(type))
{
var distance = Util.LevenshteinDistance(name, candidate.Key);
var related = distance <= threshold
|| candidate.Key.IndexOf(name, StringComparison.OrdinalIgnoreCase) >= 0
|| name.IndexOf(candidate.Key, StringComparison.OrdinalIgnoreCase) >= 0;
if (related)
var score = Util.JaroWinklerSimilarity(name, candidate.Key);
if (score < SimilarityThreshold)
{
scored.Add((candidate.Key, distance, candidate.Value));
// Coverage scoring ranks containment matches below any similarity match.
score = IsMeaningfulContainment(name, candidate.Key)
? (double)Math.Min(name.Length, candidate.Key.Length) / Math.Max(name.Length, candidate.Key.Length)
: 0;
}

if (score > 0)
{
scored.Add((candidate.Key, score, candidate.Value));
}
}

Expand All @@ -861,7 +871,7 @@ private static string ComputeSimilarMemberNames(Type type, string name)
}

var ordered = scored
.OrderBy(t => t.Distance)
.OrderByDescending(t => t.Score)
.ThenBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
.ToList();

Expand Down Expand Up @@ -895,5 +905,22 @@ private static (string Name, SuggestionKind Kind) ToSnakeCaseMemberName(MemberIn
};
}

// Without the length gates every 1-2 letter member is a substring of any long
// missed name and floods the suggestion list.
private static bool IsMeaningfulContainment(string name, string candidate)
{
const int MinFragmentLength = 3;

if (name.Length >= MinFragmentLength
&& candidate.IndexOf(name, StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}

return candidate.Length >= MinFragmentLength
&& 2 * candidate.Length >= name.Length
&& name.IndexOf(candidate, StringComparison.OrdinalIgnoreCase) >= 0;
}

}
}
86 changes: 86 additions & 0 deletions src/runtime/Util/Util.cs
Original file line number Diff line number Diff line change
Expand Up @@ -363,5 +363,91 @@ internal static int LevenshteinDistance(string a, string b)
}
return prev[m];
}

/// <summary>
/// Case-insensitive Jaro-Winkler similarity in [0, 1] (Jaro boosted by shared prefix).
/// </summary>
internal static double JaroWinklerSimilarity(string a, string b)
{
const double PrefixScale = 0.1;
const int MaxPrefixLength = 4;

a = a.ToLowerInvariant();
b = b.ToLowerInvariant();

var jaro = JaroSimilarity(a, b);

var prefix = 0;
var maxPrefix = Math.Min(MaxPrefixLength, Math.Min(a.Length, b.Length));
while (prefix < maxPrefix && a[prefix] == b[prefix])
{
prefix++;
}

return jaro + prefix * PrefixScale * (1 - jaro);
}

private static double JaroSimilarity(string a, string b)
{
if (a == b)
{
return 1;
}

var n = a.Length;
var m = b.Length;
if (n == 0 || m == 0)
{
return 0;
}

var window = Math.Max(0, Math.Max(n, m) / 2 - 1);
var aMatched = new bool[n];
var bMatched = new bool[m];

var matches = 0;
for (var i = 0; i < n; i++)
{
var lo = Math.Max(0, i - window);
var hi = Math.Min(m, i + window + 1);
for (var j = lo; j < hi; j++)
{
if (!bMatched[j] && a[i] == b[j])
{
aMatched[i] = bMatched[j] = true;
matches++;
break;
}
}
}

if (matches == 0)
{
return 0;
}

var transpositions = 0;
var k = 0;
for (var i = 0; i < n; i++)
{
if (!aMatched[i])
{
continue;
}
while (!bMatched[k])
{
k++;
}
if (a[i] != b[k])
{
transpositions++;
}
k++;
}
transpositions /= 2;

return ((double)matches / n + (double)matches / m
+ (double)(matches - transpositions) / matches) / 3;
}
}
}
26 changes: 26 additions & 0 deletions src/testing/classtest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,5 +86,31 @@ public static int[] CalculationResults()
}

public static int CalculationResult { get; set; }

// Short members, all substrings of a longer miss like 'set_account_type', which
// must not suggest them.
public static int T()
{
return 0;
}

public static int CC()
{
return 0;
}

public static void SetAccountCurrency(string currency)
{
}
}

/// <summary>
/// Supports suggestion tests for enum members that extend the guessed name with a suffix.
/// </summary>
public enum SuggestionEnum
{
InteractiveBrokersBrokerage,
InteractiveBrokersFix,
Binance,
}
}
34 changes: 34 additions & 0 deletions tests/test_class.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,40 @@ def test_missing_property_suggests_data_only():
assert "'calculation_results'" not in hint


def _suggestions(message):
"""Extract the quoted member names from a "Did you mean" hint."""
import re
return re.findall(r"'([^']+)'", message.split("Did you mean")[1])


def test_missing_attribute_does_not_suggest_short_members():
"""A long missed name must not collect 1-2 letter members via substring containment.

Every short member is a substring of a long miss, so hints used to read
"Did you mean: 'cc', 'co', 'a', 'c', 't'?" while the intended member was absent.
"""
from Python.Test import SuggestionTest

with pytest.raises(AttributeError) as exc_info:
_ = SuggestionTest.set_account_type

message = str(exc_info.value)
assert "Did you mean" in message
suggested = _suggestions(message)
assert "set_account_currency" in suggested
assert all(len(s) > 2 for s in suggested)


def test_missing_attribute_fragment_suggests_containing_member():
"""Typing a meaningful fragment of a member name still suggests that member."""
from Python.Test import SuggestionTest

with pytest.raises(AttributeError) as exc_info:
_ = SuggestionTest.currency

assert "set_account_currency" in _suggestions(str(exc_info.value))


def test_missing_static_member_no_similar():
"""A static member with no similar name keeps the standard message (no hint)."""
from System import Math
Expand Down
18 changes: 18 additions & 0 deletions tests/test_enum.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,24 @@ def test_missing_enum_member_hasattr_still_false():
assert not hasattr(DayOfWeek, "Sundey")


def test_missing_enum_member_suffix_extended_name_suggested():
"""A guessed name that the real member extends with a suffix must be suggested,
from both the PascalCase and the UPPER_SNAKE spelling of the guess."""
import re
from Python.Test import SuggestionEnum

for miss in ("InteractiveBrokers", "INTERACTIVE_BROKERS"):
with pytest.raises(AttributeError) as exc_info:
getattr(SuggestionEnum, miss)

message = str(exc_info.value)
assert "Did you mean" in message
suggested = re.findall(r"'([^']+)'", message.split("Did you mean")[1])
assert "INTERACTIVE_BROKERS_BROKERAGE" in suggested
assert "INTERACTIVE_BROKERS_FIX" in suggested
assert "BINANCE" not in suggested


def test_byte_enum():
"""Test byte enum."""
assert Test.ByteEnum.Zero == Test.ByteEnum(0)
Expand Down
Loading