Skip to content

Commit 1e5186d

Browse files
authored
Suggest nested type names verbatim and filter attribute hints by member usage (#140)
* Suggest nested type names verbatim and filter attribute hints by member usage A missing-attribute hint could suggest the exact name that just failed: accessing OptionPriceModels.quant_lib() produced "has no attribute 'quant_lib'. Did you mean: 'quant_lib'?", because the suggestion list snake-cased nested type names while ClassManager only registers nested types under their original PascalCase name. Nested types are now suggested under their original name, and suggestions are filtered by how the closest match is used from Python: a miss that best matches a method or nested type (a possible constructor call) only suggests callables, while one that best matches a field or property only suggests data members. * Keep member name conversion in ToSnakeCaseMemberName
1 parent 4ec6ca1 commit 1e5186d

3 files changed

Lines changed: 127 additions & 24 deletions

File tree

src/runtime/Types/ClassBase.cs

Lines changed: 51 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,21 @@ internal class ClassBase : ManagedType, IDeserializationCallback
2929
internal readonly Dictionary<int, MethodObject> richcompare = new();
3030
internal MaybeType type;
3131

32+
// How a member is used from Python, so a missing-attribute hint only suggests members
33+
// usable the same way as the one the user most likely meant. Nested types count as
34+
// callable: `Foo.Bar()` may be an attempted constructor call. A single exposed name can
35+
// carry both flags when e.g. a method and a property collapse to the same snake_case name.
36+
[Flags]
37+
private enum SuggestionKind
38+
{
39+
Callable = 1,
40+
Data = 2,
41+
}
42+
3243
// Reflecting over a managed type's full member set (with FlattenHierarchy) plus the
3344
// snake_case conversion is expensive, and the result never changes for a given type.
3445
// Compute it once per type.
35-
private static readonly ConcurrentDictionary<Type, HashSet<string>> _candidateNameCache = new();
46+
private static readonly ConcurrentDictionary<Type, Dictionary<string, SuggestionKind>> _candidateNameCache = new();
3647

3748
// A miss-heavy workload probes the same missing names over and over (e.g. a per-bar
3849
// getattr(self, "_optional", None) on a .NET-derived object, or a mistyped enum value).
@@ -760,8 +771,8 @@ private static string GetSuggestionHint(Type type, string name)
760771

761772
// The hint is built and cached once per (type, name); on a repeated miss this is just
762773
// a dictionary lookup. An empty string means there was nothing to suggest. The
763-
// suggested names use the same snake_case convention Python exposes members under
764-
// (see ToSnakeCaseMemberName), so they are independent of whether the access was on
774+
// suggested names use the same convention Python exposes members under (see
775+
// GetCandidateMemberNames), so they are independent of whether the access was on
765776
// an instance or the type object.
766777
return _suggestionCache.GetOrAdd((type, name),
767778
static key => ComputeSimilarMemberNames(key.Type, key.Name));
@@ -786,17 +797,18 @@ private static string GetErrorMessage(BorrowedReference value, string fallbackNa
786797
return $"object has no attribute '{fallbackName}'";
787798
}
788799

789-
// The snake_case candidate member names of a type, cached so the reflection and name
790-
// conversion happen at most once per type rather than on every attribute miss. Instance
791-
// and static members are both included, and each is converted with ToSnakeCaseMemberName
792-
// so the suggestion matches the name Python exposes it under: methods become lower_snake,
793-
// while enum values, consts and static-readonly members become UPPER_SNAKE (e.g.
794-
// DayOfWeek.SUNDAY, Math.PI, String.EMPTY).
795-
private static HashSet<string> GetCandidateMemberNames(Type type)
800+
// The candidate member names of a type, cached so the reflection and name conversion
801+
// happen at most once per type rather than on every attribute miss. Instance and static
802+
// members are both included, and each is converted with ToSnakeCaseMemberName so the
803+
// suggestion matches the name Python exposes it under: methods become lower_snake, enum
804+
// values, consts and static-readonly members become UPPER_SNAKE (e.g. DayOfWeek.SUNDAY,
805+
// Math.PI, String.EMPTY), and nested types keep their original name. Each name is tagged
806+
// with how it is used from Python so suggestions can be filtered by usage.
807+
private static Dictionary<string, SuggestionKind> GetCandidateMemberNames(Type type)
796808
{
797809
return _candidateNameCache.GetOrAdd(type, static t =>
798810
{
799-
var names = new HashSet<string>(StringComparer.Ordinal);
811+
var names = new Dictionary<string, SuggestionKind>(StringComparer.Ordinal);
800812

801813
var members = t.GetMembers(BindingFlags.Public | BindingFlags.Instance
802814
| BindingFlags.Static | BindingFlags.FlattenHierarchy);
@@ -814,7 +826,8 @@ private static HashSet<string> GetCandidateMemberNames(Type type)
814826
continue;
815827
}
816828

817-
names.Add(ToSnakeCaseMemberName(member));
829+
var (name, kind) = ToSnakeCaseMemberName(member);
830+
names[name] = names.TryGetValue(name, out var existing) ? existing | kind : kind;
818831
}
819832

820833
return names;
@@ -829,16 +842,16 @@ private static string ComputeSimilarMemberNames(Type type, string name)
829842
const int MaxSuggestions = 5;
830843
var threshold = Math.Max(2, name.Length / 3);
831844

832-
var scored = new List<(string Name, int Distance)>();
845+
var scored = new List<(string Name, int Distance, SuggestionKind Kind)>();
833846
foreach (var candidate in GetCandidateMemberNames(type))
834847
{
835-
var distance = LevenshteinDistance(name, candidate);
848+
var distance = LevenshteinDistance(name, candidate.Key);
836849
var related = distance <= threshold
837-
|| candidate.IndexOf(name, StringComparison.OrdinalIgnoreCase) >= 0
838-
|| name.IndexOf(candidate, StringComparison.OrdinalIgnoreCase) >= 0;
850+
|| candidate.Key.IndexOf(name, StringComparison.OrdinalIgnoreCase) >= 0
851+
|| name.IndexOf(candidate.Key, StringComparison.OrdinalIgnoreCase) >= 0;
839852
if (related)
840853
{
841-
scored.Add((candidate, distance));
854+
scored.Add((candidate.Key, distance, candidate.Value));
842855
}
843856
}
844857

@@ -847,24 +860,38 @@ private static string ComputeSimilarMemberNames(Type type, string name)
847860
return string.Empty;
848861
}
849862

850-
var suggestions = scored
863+
var ordered = scored
851864
.OrderBy(t => t.Distance)
852865
.ThenBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
866+
.ToList();
867+
868+
// Only suggest members used the same way as the closest match, the member the user
869+
// most likely meant: a miss that best matches a method or nested type gets callable
870+
// suggestions only, one that best matches a field/property gets data suggestions
871+
// only. Mixing the two would suggest names the caller cannot use the same way.
872+
var kind = ordered[0].Kind;
873+
var suggestions = ordered
874+
.Where(t => (t.Kind & kind) != 0)
853875
.Take(MaxSuggestions)
854876
.Select(t => $"'{t.Name}'");
855877

856878
return " Did you mean: " + string.Join(", ", suggestions) + "?";
857879
}
858880

859-
private static string ToSnakeCaseMemberName(MemberInfo member)
881+
// Converts a member to the name Python exposes it under, tagged with how it is used.
882+
// The field/property overloads of ToSnakeCase are used so const and static-readonly
883+
// members are converted to UPPER_CASE. Nested types keep their original name verbatim:
884+
// ClassManager registers no snake_case alias for them, and they count as callable since
885+
// accessing one may be an attempted constructor call.
886+
private static (string Name, SuggestionKind Kind) ToSnakeCaseMemberName(MemberInfo member)
860887
{
861-
// Use the field/property overloads so const and static-readonly members
862-
// are converted to UPPER_CASE, matching how they are exposed to Python.
863888
return member switch
864889
{
865-
FieldInfo fieldInfo => fieldInfo.ToSnakeCase(),
866-
PropertyInfo propertyInfo => propertyInfo.ToSnakeCase(),
867-
_ => member.Name.ToSnakeCase(),
890+
Type => (member.Name, SuggestionKind.Callable),
891+
MethodBase => (member.Name.ToSnakeCase(), SuggestionKind.Callable),
892+
FieldInfo fieldInfo => (fieldInfo.ToSnakeCase(), SuggestionKind.Data),
893+
PropertyInfo propertyInfo => (propertyInfo.ToSnakeCase(), SuggestionKind.Data),
894+
_ => (member.Name.ToSnakeCase(), SuggestionKind.Data),
868895
};
869896
}
870897

src/testing/classtest.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,32 @@ public ClassCtorTest2(string v)
5959
internal class InternalClass
6060
{
6161
}
62+
63+
/// <summary>
64+
/// Supports missing-attribute suggestion ("Did you mean") unit tests: a nested type,
65+
/// a method and a property with deliberately similar names, so tests can assert that
66+
/// suggestions are filtered by how the intended member is used from Python.
67+
/// </summary>
68+
public class SuggestionTest
69+
{
70+
public static class Calculator
71+
{
72+
public static int Add(int a, int b)
73+
{
74+
return a + b;
75+
}
76+
}
77+
78+
public static int Calculate()
79+
{
80+
return 0;
81+
}
82+
83+
public static int[] CalculationResults()
84+
{
85+
return new int[0];
86+
}
87+
88+
public static int CalculationResult { get; set; }
89+
}
6290
}

tests/test_class.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,54 @@ def test_missing_static_field_suggests_similar():
163163
assert "'EMPTY'" in message
164164

165165

166+
def test_missing_nested_type_suggests_original_name():
167+
"""A miss that matches a nested type suggests its original PascalCase name.
168+
169+
Nested types are exposed under their original name only (no snake_case alias),
170+
so suggesting the snake-cased name would point at another missing attribute.
171+
"""
172+
from Python.Test import SuggestionTest
173+
174+
with pytest.raises(AttributeError) as exc_info:
175+
_ = SuggestionTest.calculator
176+
177+
message = str(exc_info.value)
178+
assert "Did you mean" in message
179+
hint = message.split("Did you mean")[1]
180+
assert "'Calculator'" in hint
181+
# The snake-cased nested type name is not accessible, so it must not be suggested.
182+
assert "'calculator'" not in hint
183+
184+
185+
def test_missing_method_suggests_callables_only():
186+
"""A miss that best matches a method suggests methods and nested types only.
187+
188+
Nested types are included because the access may be an attempted constructor
189+
call, but similarly-named properties are excluded: they are not callable.
190+
"""
191+
from Python.Test import SuggestionTest
192+
193+
with pytest.raises(AttributeError) as exc_info:
194+
_ = SuggestionTest.calculat
195+
196+
hint = str(exc_info.value).split("Did you mean")[1]
197+
assert "'calculate'" in hint
198+
assert "'Calculator'" in hint
199+
assert "'calculation_result'" not in hint
200+
201+
202+
def test_missing_property_suggests_data_only():
203+
"""A miss that best matches a property suggests fields/properties only."""
204+
from Python.Test import SuggestionTest
205+
206+
with pytest.raises(AttributeError) as exc_info:
207+
_ = SuggestionTest.calculation_resul
208+
209+
hint = str(exc_info.value).split("Did you mean")[1]
210+
assert "'calculation_result'" in hint
211+
assert "'calculation_results'" not in hint
212+
213+
166214
def test_missing_static_member_no_similar():
167215
"""A static member with no similar name keeps the standard message (no hint)."""
168216
from System import Math

0 commit comments

Comments
 (0)