Skip to content

Commit b4b0201

Browse files
jhonabreulclaude
andcommitted
Extend AttributeError suggestions to type-object (static and enum) misses
A missing attribute on a reflected type object -- a mistyped static member or enum value such as DayOfWeek.Sundey -- previously raised the bare CPython "type object 'X' has no attribute 'Y'" with no hint, because the miss hook was installed on reflected types (governing their instances) but type-object access is governed by the CLR metatype. Install the same miss-only __getattr__ hook on the CLR metatype (allowing the redirect when tp_getattro is type_getattro, not just the generic getattr), so a type-object miss is enriched the same way instance misses are. BuildMissing AttributeMessage now resolves the target from either a CLRObject (instance) or a ClassBase (type object) and uses CPython's "type object 'T'" wording for the latter. Suggestions reuse the existing ranking/cache and the snake_case convention Python exposes members under (ToSnakeCaseMemberName): methods become lower_snake while enum values, consts and static-readonly members become UPPER_SNAKE, e.g. DayOfWeek.Sundey -> "Did you mean: 'SUNDAY'?", Math.PII -> 'PI', String.Empy -> 'EMPTY'. All suggested names resolve. Hits, imports and hasattr on type objects are unaffected (the hook is miss-only). Adds tests for enum, static const, static-readonly field and static method misses, the no-similar case and hasattr, in test_enum.py and test_class.py. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a09ba60 commit b4b0201

5 files changed

Lines changed: 187 additions & 22 deletions

File tree

src/runtime/AttributeErrorHint.cs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ internal static class AttributeErrorHint
4040
private static IntPtr _hookSlot;
4141
// Address of PyObject_GenericGetAttr, used to detect types we may safely redirect.
4242
private static IntPtr _genericGetAttr;
43+
// Address of type_getattro (PyType_Type.tp_getattro). The CLR metatype uses it, so we
44+
// allow redirecting it too: that is how attribute access on a reflected type object
45+
// (static members, enum values) gets the miss hook.
46+
private static IntPtr _typeGetAttro;
4347

4448
private static bool IsReady => _methodDef != IntPtr.Zero && _hookSlot != IntPtr.Zero;
4549

@@ -48,6 +52,7 @@ internal static void Initialize()
4852
try
4953
{
5054
_genericGetAttr = Util.ReadIntPtr(Runtime.PyBaseObjectType, TypeOffset.tp_getattro);
55+
_typeGetAttro = Util.ReadIntPtr(Runtime.PyTypeType, TypeOffset.tp_getattro);
5156

5257
if (_methodDef == IntPtr.Zero)
5358
{
@@ -70,6 +75,11 @@ internal static void Initialize()
7075

7176
using var probe = globals["__clr_getattr_probe__"];
7277
_hookSlot = Util.ReadIntPtr(probe.Reference, TypeOffset.tp_getattro);
78+
79+
// Install the hook on the CLR metatype so that a miss on a reflected type
80+
// object's own attribute (a mistyped static member or enum value, e.g.
81+
// DayOfWeek.Sundey) is enriched the same way instance attribute misses are.
82+
Install(MetaType.ClrMetaTypeReference);
7383
}
7484
catch (Exception e)
7585
{
@@ -94,7 +104,12 @@ internal static void Install(BorrowedReference type)
94104
return;
95105
}
96106

97-
if (Util.ReadIntPtr(type, TypeOffset.tp_getattro) != _genericGetAttr)
107+
var getattro = Util.ReadIntPtr(type, TypeOffset.tp_getattro);
108+
// Only redirect types that still use one of the standard lookups: instances use the
109+
// generic getattr, the CLR metatype uses type_getattro. Types with a custom
110+
// tp_getattro (dynamic objects, modules, interfaces, ...) handle misses themselves
111+
// and are left untouched.
112+
if (getattro != _genericGetAttr && getattro != _typeGetAttro)
98113
{
99114
return;
100115
}
@@ -158,6 +173,7 @@ internal static void Shutdown()
158173
// are reused by the next Initialize.
159174
_hookSlot = IntPtr.Zero;
160175
_genericGetAttr = IntPtr.Zero;
176+
_typeGetAttro = IntPtr.Zero;
161177
}
162178
}
163179
}

src/runtime/Types/ClassBase.cs

Lines changed: 66 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,10 @@ internal class ClassBase : ManagedType, IDeserializationCallback
3535
private static readonly ConcurrentDictionary<Type, HashSet<string>> _candidateNameCache = new();
3636

3737
// A miss-heavy workload probes the same missing names over and over (e.g. a per-bar
38-
// getattr(self, "_optional", None) on a .NET-derived object). Memoize the fully-built
39-
// " Did you mean: ...?" hint (empty when there is nothing to suggest) per
40-
// (type, missing-name) so repeats are a dictionary lookup instead of an O(members)
41-
// reflection + Levenshtein scan on every miss.
38+
// getattr(self, "_optional", None) on a .NET-derived object, or a mistyped enum value).
39+
// Memoize the fully-built " Did you mean: ...?" hint (empty when there is nothing to
40+
// suggest) per (type, missing-name) so repeats are a dictionary lookup instead of an
41+
// O(members) reflection + Levenshtein scan on every miss.
4242
private static readonly ConcurrentDictionary<(Type Type, string Name), string> _suggestionCache = new();
4343

4444
internal ClassBase(Type tp)
@@ -676,26 +676,61 @@ internal static void AppendAttributeErrorSuggestions(BorrowedReference ob, Borro
676676
/// </summary>
677677
internal static string BuildMissingAttributeMessage(PyObject self, string name)
678678
{
679-
var typeName = "object";
680679
try
681680
{
682-
using var pyType = self.GetPythonType();
683-
typeName = pyType.Name;
681+
if (TryGetSuggestionTarget(self.Reference, out var type, out var staticScope))
682+
{
683+
// Match CPython's wording: instances say "'T' object ...", whereas an access
684+
// on the type object itself (a missing static member or enum value) says
685+
// "type object 'T' ...".
686+
var baseMessage = staticScope
687+
? $"type object '{type!.Name}' has no attribute '{name}'"
688+
: $"'{PythonTypeName(self)}' object has no attribute '{name}'";
689+
return baseMessage + GetSuggestionHint(type!, name);
690+
}
684691
}
685692
catch
686693
{
687-
// fall back to the generic type name
694+
// never let message building turn into a different exception
688695
}
689696

690-
var message = $"'{typeName}' object has no attribute '{name}'";
697+
return $"'{PythonTypeName(self)}' object has no attribute '{name}'";
698+
}
699+
700+
private static string PythonTypeName(PyObject self)
701+
{
691702
try
692703
{
693-
return message + GetSuggestionHint(self.Reference, name);
704+
using var pyType = self.GetPythonType();
705+
return pyType.Name;
694706
}
695707
catch
696708
{
697-
// never let suggestion building turn into a different exception
698-
return message;
709+
return "object";
710+
}
711+
}
712+
713+
/// <summary>
714+
/// Resolves the managed <see cref="Type"/> whose members should be searched for a
715+
/// missing-attribute suggestion, and whether the access was on the type object itself
716+
/// (<paramref name="staticScope"/> = true, for static members and enum values) rather
717+
/// than on an instance. Returns false for objects that are not reflected .NET types.
718+
/// </summary>
719+
private static bool TryGetSuggestionTarget(BorrowedReference ob, out Type? type, out bool staticScope)
720+
{
721+
type = null;
722+
staticScope = false;
723+
switch (GetManagedObject(ob))
724+
{
725+
case CLRObject clrObj when clrObj.inst is not null:
726+
type = clrObj.inst.GetType();
727+
return true;
728+
case ClassBase classBase when classBase.type.Valid:
729+
type = classBase.type.Value;
730+
staticScope = true;
731+
return true;
732+
default:
733+
return false;
699734
}
700735
}
701736

@@ -707,19 +742,27 @@ internal static string BuildMissingAttributeMessage(PyObject self, string name)
707742
/// </summary>
708743
private static string GetSuggestionHint(BorrowedReference ob, string name)
709744
{
710-
if (string.IsNullOrEmpty(name) || name.StartsWith("__", StringComparison.Ordinal))
745+
if (!TryGetSuggestionTarget(ob, out var type, out _))
711746
{
712747
return string.Empty;
713748
}
714749

715-
if (GetManagedObject(ob) is not CLRObject clrObj || clrObj.inst is null)
750+
return GetSuggestionHint(type!, name);
751+
}
752+
753+
private static string GetSuggestionHint(Type type, string name)
754+
{
755+
if (string.IsNullOrEmpty(name) || name.StartsWith("__", StringComparison.Ordinal))
716756
{
717757
return string.Empty;
718758
}
719759

720-
// The hint is built and cached once per (type, name); on a repeated miss this is
721-
// just a dictionary lookup. An empty string means there was nothing to suggest.
722-
return _suggestionCache.GetOrAdd((clrObj.inst.GetType(), name),
760+
// The hint is built and cached once per (type, name); on a repeated miss this is just
761+
// a dictionary lookup. An empty string means there was nothing to suggest. The
762+
// suggested names use the same snake_case convention Python exposes members under
763+
// (see ToSnakeCaseMemberName), so they are independent of whether the access was on
764+
// an instance or the type object.
765+
return _suggestionCache.GetOrAdd((type, name),
723766
static key => ComputeSimilarMemberNames(key.Type, key.Name));
724767
}
725768

@@ -742,8 +785,12 @@ private static string GetErrorMessage(BorrowedReference value, string fallbackNa
742785
return $"object has no attribute '{fallbackName}'";
743786
}
744787

745-
// The deduplicated snake_case member names of a type, cached so the reflection and
746-
// string conversion happen at most once per type rather than on every attribute miss.
788+
// The snake_case candidate member names of a type, cached so the reflection and name
789+
// conversion happen at most once per type rather than on every attribute miss. Instance
790+
// and static members are both included, and each is converted with ToSnakeCaseMemberName
791+
// so the suggestion matches the name Python exposes it under: methods become lower_snake,
792+
// while enum values, consts and static-readonly members become UPPER_SNAKE (e.g.
793+
// DayOfWeek.SUNDAY, Math.PI, String.EMPTY).
747794
private static HashSet<string> GetCandidateMemberNames(Type type)
748795
{
749796
return _candidateNameCache.GetOrAdd(type, static t =>
@@ -766,8 +813,6 @@ private static HashSet<string> GetCandidateMemberNames(Type type)
766813
continue;
767814
}
768815

769-
// Suggest the snake_case alias, since that is the fork's PEP8-style
770-
// public API surface (members are exposed in both Pascal and snake case).
771816
names.Add(ToSnakeCaseMemberName(member));
772817
}
773818

src/runtime/Types/MetaType.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ internal sealed class MetaType : ManagedType
2626
"__subclasscheck__",
2727
};
2828

29+
/// <summary>
30+
/// The CLR metatype object. Reflected .NET types are instances of it, so wiring the
31+
/// AttributeError miss hook here enriches misses on a type object's own attributes
32+
/// (static members and enum values).
33+
/// </summary>
34+
internal static BorrowedReference ClrMetaTypeReference => PyCLRMetaType.Reference;
35+
2936
/// <summary>
3037
/// Metatype initialization. This bootstraps the CLR metatype to life.
3138
/// </summary>

tests/test_class.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,66 @@ def test_missing_attribute_hasattr_still_false():
123123
assert hasattr(s, "Length")
124124

125125

126+
def test_missing_static_method_suggests_similar():
127+
"""A mistyped static method on a type object suggests the similar member."""
128+
from System import Math
129+
130+
with pytest.raises(AttributeError) as exc_info:
131+
_ = Math.Sqrtt
132+
133+
message = str(exc_info.value)
134+
assert "type object 'Math'" in message
135+
assert "Sqrtt" in message
136+
assert "Did you mean" in message
137+
# Methods are exposed lower_snake, so the suggestion is 'sqrt'. Quoted so the assertion
138+
# matches the suggestion, not the typo 'Sqrtt'.
139+
assert "'sqrt'" in message
140+
141+
142+
def test_missing_static_const_suggests_similar():
143+
"""A mistyped static const (Math.PI) suggests the UPPER_SNAKE constant name."""
144+
from System import Math
145+
146+
with pytest.raises(AttributeError) as exc_info:
147+
_ = Math.PII
148+
149+
message = str(exc_info.value)
150+
assert "Did you mean" in message
151+
# Consts are exposed UPPER_SNAKE; quoted so it matches the suggestion, not the typo 'PII'.
152+
assert "'PI'" in message
153+
154+
155+
def test_missing_static_field_suggests_similar():
156+
"""A mistyped static-readonly field (String.Empty) suggests the UPPER_SNAKE name."""
157+
with pytest.raises(AttributeError) as exc_info:
158+
_ = System.String.Empy
159+
160+
message = str(exc_info.value)
161+
assert "Did you mean" in message
162+
# static-readonly fields are exposed UPPER_SNAKE -> String.EMPTY.
163+
assert "'EMPTY'" in message
164+
165+
166+
def test_missing_static_member_no_similar():
167+
"""A static member with no similar name keeps the standard message (no hint)."""
168+
from System import Math
169+
170+
with pytest.raises(AttributeError) as exc_info:
171+
_ = Math.Zzzzzz
172+
173+
message = str(exc_info.value)
174+
assert "Zzzzzz" in message
175+
assert "Did you mean" not in message
176+
177+
178+
def test_missing_static_member_hasattr_still_false():
179+
"""The type-object miss hook must not break hasattr() on a type."""
180+
from System import Math
181+
182+
assert hasattr(Math, "Sqrt")
183+
assert not hasattr(Math, "Sqrtt")
184+
185+
126186
def test_missing_attribute_hook_is_native():
127187
"""The __getattr__ hook must be a native method descriptor.
128188

tests/test_enum.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,43 @@ def test_enum_get_member():
3131
assert DayOfWeek.Saturday == DayOfWeek(6)
3232

3333

34+
def test_missing_enum_member_suggests_similar():
35+
"""A mistyped enum member suggests the correct member by its .NET name."""
36+
from System import DayOfWeek
37+
38+
with pytest.raises(AttributeError) as exc_info:
39+
_ = DayOfWeek.Sundey
40+
41+
message = str(exc_info.value)
42+
# Access on the type object itself uses the "type object 'T'" wording.
43+
assert "type object 'DayOfWeek'" in message
44+
assert "Sundey" in message
45+
assert "Did you mean" in message
46+
# Enum values are exposed in UPPER_SNAKE (the fork's PEP8 constant convention), so that is
47+
# the form suggested -- DayOfWeek.SUNDAY, not 'Sunday'.
48+
assert "'SUNDAY'" in message
49+
50+
51+
def test_missing_enum_member_no_similar():
52+
"""An enum member with no similar name keeps the standard message (no hint)."""
53+
from System import DayOfWeek
54+
55+
with pytest.raises(AttributeError) as exc_info:
56+
_ = DayOfWeek.Xyzzy
57+
58+
message = str(exc_info.value)
59+
assert "Xyzzy" in message
60+
assert "Did you mean" not in message
61+
62+
63+
def test_missing_enum_member_hasattr_still_false():
64+
"""Enriching the AttributeError must not break hasattr() on enum types."""
65+
from System import DayOfWeek
66+
67+
assert hasattr(DayOfWeek, "Sunday")
68+
assert not hasattr(DayOfWeek, "Sundey")
69+
70+
3471
def test_byte_enum():
3572
"""Test byte enum."""
3673
assert Test.ByteEnum.Zero == Test.ByteEnum(0)

0 commit comments

Comments
 (0)