From 32bddf4517ce484afd96562c0c1128fdcc9b5e67 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Mon, 6 Jul 2026 19:11:30 -0400 Subject: [PATCH 1/5] Cache AttributeError member-name suggestions per type and (type, name) Building the "Did you mean ...?" hint for a missing attribute reflected over the managed type's full member set (GetMembers with FlattenHierarchy), snake_cased every member, and ran a Levenshtein scan -- on every miss, with no caching. getattr(obj, name, default) and hasattr trigger it too, since the work happens on the miss path before CPython suppresses the error. A workload that probes the same missing names repeatedly (e.g. a per-bar getattr(self, "_optional", None) on a .NET-derived object) therefore paid the full O(members) reflection + ranking cost on every access. Add two caches in ClassBase: - _candidateNameCache (Type -> string[]): the reflected, deduplicated snake_case member names, computed once per type. - _suggestionCache ((Type, name) -> string[]): the ranked suggestion list, memoized so repeated misses of the same name are a dictionary lookup. Suggestions and error messages are unchanged; only the repeated computation is removed. On a real Lean multi-symbol minute backtest the suggestion path dominated ~93% of OnData CPU; with this change the backtest goes from not finishing (aborted, >15x slower) to ~93s, on par with the last build without the suggestion feature (~90s), with identical results. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/runtime/Types/ClassBase.cs | 82 +++++++++++++++++++++++----------- tests/test_class.py | 19 ++++++++ 2 files changed, 76 insertions(+), 25 deletions(-) diff --git a/src/runtime/Types/ClassBase.cs b/src/runtime/Types/ClassBase.cs index 7e831d17f..4ec57c679 100644 --- a/src/runtime/Types/ClassBase.cs +++ b/src/runtime/Types/ClassBase.cs @@ -1,5 +1,6 @@ using System; using System.Collections; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -732,38 +733,69 @@ private static string GetErrorMessage(BorrowedReference value, string fallbackNa return $"object has no attribute '{fallbackName}'"; } - private static List GetSimilarMemberNames(Type type, string name) - { - const int MaxSuggestions = 5; - var threshold = Math.Max(2, name.Length / 3); + // Reflecting over a managed type's full member set (with FlattenHierarchy) plus the + // snake_case conversion is expensive, and the result never changes for a given type. + // Compute it once per type. + private static readonly ConcurrentDictionary _candidateNameCache = new(); - var seen = new HashSet(StringComparer.Ordinal); - var scored = new List<(string Name, int Distance)>(); + // A miss-heavy workload probes the same missing names over and over (e.g. a per-bar + // getattr(self, "_optional", None) on a .NET-derived object). Memoize the ranked + // suggestion list per (type, missing-name) so repeats are a dictionary lookup instead + // of an O(members) reflection + Levenshtein scan on every miss. + private static readonly ConcurrentDictionary<(Type Type, string Name), string[]> _suggestionCache = new(); + + private static IReadOnlyList GetSimilarMemberNames(Type type, string name) + { + return _suggestionCache.GetOrAdd((type, name), + static key => ComputeSimilarMemberNames(key.Type, key.Name)); + } - var members = type.GetMembers(BindingFlags.Public | BindingFlags.Instance - | BindingFlags.Static | BindingFlags.FlattenHierarchy); - foreach (var member in members) + // The deduplicated snake_case member names of a type, cached so the reflection and + // string conversion happen at most once per type rather than on every attribute miss. + private static string[] GetCandidateMemberNames(Type type) + { + return _candidateNameCache.GetOrAdd(type, static t => { - // Skip property/event accessors, operators and other special-name methods, - // as well as compiler-generated members; none are accessible by name. - if (member is MethodBase { IsSpecialName: true }) - { - continue; - } + var seen = new HashSet(StringComparer.Ordinal); + var names = new List(); - if (member.Name.Length == 0 || member.Name[0] == '<') + var members = t.GetMembers(BindingFlags.Public | BindingFlags.Instance + | BindingFlags.Static | BindingFlags.FlattenHierarchy); + foreach (var member in members) { - continue; - } + // Skip property/event accessors, operators and other special-name methods, + // as well as compiler-generated members; none are accessible by name. + if (member is MethodBase { IsSpecialName: true }) + { + continue; + } - // Suggest the snake_case alias, since that is the fork's PEP8-style - // public API surface (members are exposed in both Pascal and snake case). - var candidate = ToSnakeCaseMemberName(member); - if (!seen.Add(candidate)) - { - continue; + if (member.Name.Length == 0 || member.Name[0] == '<') + { + continue; + } + + // Suggest the snake_case alias, since that is the fork's PEP8-style + // public API surface (members are exposed in both Pascal and snake case). + var candidate = ToSnakeCaseMemberName(member); + if (seen.Add(candidate)) + { + names.Add(candidate); + } } + return names.ToArray(); + }); + } + + private static string[] ComputeSimilarMemberNames(Type type, string name) + { + const int MaxSuggestions = 5; + var threshold = Math.Max(2, name.Length / 3); + + var scored = new List<(string Name, int Distance)>(); + foreach (var candidate in GetCandidateMemberNames(type)) + { var distance = LevenshteinDistance(name, candidate); var related = distance <= threshold || candidate.IndexOf(name, StringComparison.OrdinalIgnoreCase) >= 0 @@ -779,7 +811,7 @@ private static List GetSimilarMemberNames(Type type, string name) .ThenBy(t => t.Name, StringComparer.OrdinalIgnoreCase) .Take(MaxSuggestions) .Select(t => t.Name) - .ToList(); + .ToArray(); } private static string ToSnakeCaseMemberName(MemberInfo member) diff --git a/tests/test_class.py b/tests/test_class.py index bfa40714c..2f9f57778 100644 --- a/tests/test_class.py +++ b/tests/test_class.py @@ -96,6 +96,25 @@ def test_missing_attribute_no_similar_members(): assert "Did you mean" not in message +def test_missing_attribute_suggestion_is_cached_and_stable(): + """Repeated misses of the same attribute must return identical suggestions. + + The suggestion list is memoized per (type, name) so a miss-heavy workload does + not re-run the reflection + Levenshtein scan on every access. The cached result + must stay correct and identical across repeated lookups. + """ + s = System.String("this is a test") + + messages = [] + for _ in range(3): + with pytest.raises(AttributeError) as exc_info: + _ = s.lenght + messages.append(str(exc_info.value)) + + assert all("Did you mean" in m and "length" in m for m in messages) + assert messages[0] == messages[1] == messages[2] + + def test_missing_attribute_hasattr_still_false(): """Enriching the AttributeError must not break hasattr() (it must stay False).""" s = System.String("this is a test") From 4c682fc77b0f225d9010b0f39ca3b396eb421904 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 7 Jul 2026 09:17:03 -0400 Subject: [PATCH 2/5] Cache the built suggestion hint string instead of the member-name list Store the fully-built " Did you mean: ...?" hint (empty when there is nothing to suggest) in _suggestionCache rather than the ranked string[]. The hint is now assembled once inside ComputeSimilarMemberNames and memoized per (type, missing-name); GetSuggestionHint just returns the cached string and appends it, dropping the per-miss Count check, Select and string.Join. Behavior and message text are unchanged; a repeated miss is now a single dictionary lookup returning the ready-made hint. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/runtime/Types/ClassBase.cs | 43 +++++++++++++++++----------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/src/runtime/Types/ClassBase.cs b/src/runtime/Types/ClassBase.cs index 4ec57c679..f154e43d9 100644 --- a/src/runtime/Types/ClassBase.cs +++ b/src/runtime/Types/ClassBase.cs @@ -705,13 +705,10 @@ private static string GetSuggestionHint(BorrowedReference ob, string name) return string.Empty; } - var suggestions = GetSimilarMemberNames(clrObj.inst.GetType(), name); - if (suggestions.Count == 0) - { - return string.Empty; - } - - return " Did you mean: " + string.Join(", ", suggestions.Select(s => $"'{s}'")) + "?"; + // The hint is built and cached once per (type, name); on a repeated miss this is + // just a dictionary lookup. An empty string means there was nothing to suggest. + return _suggestionCache.GetOrAdd((clrObj.inst.GetType(), name), + static key => ComputeSimilarMemberNames(key.Type, key.Name)); } private static string GetErrorMessage(BorrowedReference value, string fallbackName) @@ -739,16 +736,11 @@ private static string GetErrorMessage(BorrowedReference value, string fallbackNa private static readonly ConcurrentDictionary _candidateNameCache = new(); // A miss-heavy workload probes the same missing names over and over (e.g. a per-bar - // getattr(self, "_optional", None) on a .NET-derived object). Memoize the ranked - // suggestion list per (type, missing-name) so repeats are a dictionary lookup instead - // of an O(members) reflection + Levenshtein scan on every miss. - private static readonly ConcurrentDictionary<(Type Type, string Name), string[]> _suggestionCache = new(); - - private static IReadOnlyList GetSimilarMemberNames(Type type, string name) - { - return _suggestionCache.GetOrAdd((type, name), - static key => ComputeSimilarMemberNames(key.Type, key.Name)); - } + // getattr(self, "_optional", None) on a .NET-derived object). 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. + private static readonly ConcurrentDictionary<(Type Type, string Name), string> _suggestionCache = new(); // The deduplicated snake_case member names of a type, cached so the reflection and // string conversion happen at most once per type rather than on every attribute miss. @@ -788,7 +780,10 @@ private static string[] GetCandidateMemberNames(Type type) }); } - private static string[] ComputeSimilarMemberNames(Type type, string name) + // 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). + private static string ComputeSimilarMemberNames(Type type, string name) { const int MaxSuggestions = 5; var threshold = Math.Max(2, name.Length / 3); @@ -806,12 +801,18 @@ private static string[] ComputeSimilarMemberNames(Type type, string name) } } - return scored + if (scored.Count == 0) + { + return string.Empty; + } + + var suggestions = scored .OrderBy(t => t.Distance) .ThenBy(t => t.Name, StringComparer.OrdinalIgnoreCase) .Take(MaxSuggestions) - .Select(t => t.Name) - .ToArray(); + .Select(t => $"'{t.Name}'"); + + return " Did you mean: " + string.Join(", ", suggestions) + "?"; } private static string ToSnakeCaseMemberName(MemberInfo member) From a09ba606f9f5e9ade37fc91e7e9b4dbec12b4f43 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 7 Jul 2026 09:25:53 -0400 Subject: [PATCH 3/5] Move suggestion caches to the class field block; simplify candidate collection Move the _candidateNameCache and _suggestionCache declarations up to the class field block with the other fields. In GetCandidateMemberNames, collect the snake_case names into a single HashSet (named names) instead of a HashSet plus a List, and return the set directly; deduplication and storage are the same collection. Candidate iteration order no longer matters -- suggestions are ordered by edit distance and then by name. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/runtime/Types/ClassBase.cs | 37 +++++++++++++++------------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/src/runtime/Types/ClassBase.cs b/src/runtime/Types/ClassBase.cs index f154e43d9..3e3c6be5a 100644 --- a/src/runtime/Types/ClassBase.cs +++ b/src/runtime/Types/ClassBase.cs @@ -29,6 +29,18 @@ internal class ClassBase : ManagedType, IDeserializationCallback internal readonly Dictionary richcompare = new(); internal MaybeType type; + // Reflecting over a managed type's full member set (with FlattenHierarchy) plus the + // snake_case conversion is expensive, and the result never changes for a given type. + // Compute it once per type. + private static readonly ConcurrentDictionary> _candidateNameCache = new(); + + // A miss-heavy workload probes the same missing names over and over (e.g. a per-bar + // getattr(self, "_optional", None) on a .NET-derived object). 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. + private static readonly ConcurrentDictionary<(Type Type, string Name), string> _suggestionCache = new(); + internal ClassBase(Type tp) { if (tp is null) throw new ArgumentNullException(nameof(type)); @@ -730,26 +742,13 @@ private static string GetErrorMessage(BorrowedReference value, string fallbackNa return $"object has no attribute '{fallbackName}'"; } - // Reflecting over a managed type's full member set (with FlattenHierarchy) plus the - // snake_case conversion is expensive, and the result never changes for a given type. - // Compute it once per type. - private static readonly ConcurrentDictionary _candidateNameCache = new(); - - // A miss-heavy workload probes the same missing names over and over (e.g. a per-bar - // getattr(self, "_optional", None) on a .NET-derived object). 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. - private static readonly ConcurrentDictionary<(Type Type, string Name), string> _suggestionCache = new(); - // The deduplicated snake_case member names of a type, cached so the reflection and // string conversion happen at most once per type rather than on every attribute miss. - private static string[] GetCandidateMemberNames(Type type) + private static HashSet GetCandidateMemberNames(Type type) { return _candidateNameCache.GetOrAdd(type, static t => { - var seen = new HashSet(StringComparer.Ordinal); - var names = new List(); + var names = new HashSet(StringComparer.Ordinal); var members = t.GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.FlattenHierarchy); @@ -769,14 +768,10 @@ private static string[] GetCandidateMemberNames(Type type) // Suggest the snake_case alias, since that is the fork's PEP8-style // public API surface (members are exposed in both Pascal and snake case). - var candidate = ToSnakeCaseMemberName(member); - if (seen.Add(candidate)) - { - names.Add(candidate); - } + names.Add(ToSnakeCaseMemberName(member)); } - return names.ToArray(); + return names; }); } From b4b0201674f5961a18dbd2c1cca8ea217a24b978 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 7 Jul 2026 10:11:35 -0400 Subject: [PATCH 4/5] 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) --- src/runtime/AttributeErrorHint.cs | 18 ++++++- src/runtime/Types/ClassBase.cs | 87 +++++++++++++++++++++++-------- src/runtime/Types/MetaType.cs | 7 +++ tests/test_class.py | 60 +++++++++++++++++++++ tests/test_enum.py | 37 +++++++++++++ 5 files changed, 187 insertions(+), 22 deletions(-) diff --git a/src/runtime/AttributeErrorHint.cs b/src/runtime/AttributeErrorHint.cs index 7faa10996..876b2a838 100644 --- a/src/runtime/AttributeErrorHint.cs +++ b/src/runtime/AttributeErrorHint.cs @@ -40,6 +40,10 @@ internal static class AttributeErrorHint private static IntPtr _hookSlot; // Address of PyObject_GenericGetAttr, used to detect types we may safely redirect. private static IntPtr _genericGetAttr; + // Address of type_getattro (PyType_Type.tp_getattro). The CLR metatype uses it, so we + // allow redirecting it too: that is how attribute access on a reflected type object + // (static members, enum values) gets the miss hook. + private static IntPtr _typeGetAttro; private static bool IsReady => _methodDef != IntPtr.Zero && _hookSlot != IntPtr.Zero; @@ -48,6 +52,7 @@ internal static void Initialize() try { _genericGetAttr = Util.ReadIntPtr(Runtime.PyBaseObjectType, TypeOffset.tp_getattro); + _typeGetAttro = Util.ReadIntPtr(Runtime.PyTypeType, TypeOffset.tp_getattro); if (_methodDef == IntPtr.Zero) { @@ -70,6 +75,11 @@ internal static void Initialize() using var probe = globals["__clr_getattr_probe__"]; _hookSlot = Util.ReadIntPtr(probe.Reference, TypeOffset.tp_getattro); + + // Install the hook on the CLR metatype so that a miss on a reflected type + // object's own attribute (a mistyped static member or enum value, e.g. + // DayOfWeek.Sundey) is enriched the same way instance attribute misses are. + Install(MetaType.ClrMetaTypeReference); } catch (Exception e) { @@ -94,7 +104,12 @@ internal static void Install(BorrowedReference type) return; } - if (Util.ReadIntPtr(type, TypeOffset.tp_getattro) != _genericGetAttr) + var getattro = Util.ReadIntPtr(type, TypeOffset.tp_getattro); + // Only redirect types that still use one of the standard lookups: instances use the + // generic getattr, the CLR metatype uses type_getattro. Types with a custom + // tp_getattro (dynamic objects, modules, interfaces, ...) handle misses themselves + // and are left untouched. + if (getattro != _genericGetAttr && getattro != _typeGetAttro) { return; } @@ -158,6 +173,7 @@ internal static void Shutdown() // are reused by the next Initialize. _hookSlot = IntPtr.Zero; _genericGetAttr = IntPtr.Zero; + _typeGetAttro = IntPtr.Zero; } } } diff --git a/src/runtime/Types/ClassBase.cs b/src/runtime/Types/ClassBase.cs index 3e3c6be5a..b1b89a2aa 100644 --- a/src/runtime/Types/ClassBase.cs +++ b/src/runtime/Types/ClassBase.cs @@ -35,10 +35,10 @@ internal class ClassBase : ManagedType, IDeserializationCallback private static readonly ConcurrentDictionary> _candidateNameCache = new(); // A miss-heavy workload probes the same missing names over and over (e.g. a per-bar - // getattr(self, "_optional", None) on a .NET-derived object). 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. + // 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. private static readonly ConcurrentDictionary<(Type Type, string Name), string> _suggestionCache = new(); internal ClassBase(Type tp) @@ -676,26 +676,61 @@ internal static void AppendAttributeErrorSuggestions(BorrowedReference ob, Borro /// internal static string BuildMissingAttributeMessage(PyObject self, string name) { - var typeName = "object"; try { - using var pyType = self.GetPythonType(); - typeName = pyType.Name; + if (TryGetSuggestionTarget(self.Reference, out var type, out var staticScope)) + { + // Match CPython's wording: instances say "'T' object ...", whereas an access + // on the type object itself (a missing static member or enum value) says + // "type object 'T' ...". + var baseMessage = staticScope + ? $"type object '{type!.Name}' has no attribute '{name}'" + : $"'{PythonTypeName(self)}' object has no attribute '{name}'"; + return baseMessage + GetSuggestionHint(type!, name); + } } catch { - // fall back to the generic type name + // never let message building turn into a different exception } - var message = $"'{typeName}' object has no attribute '{name}'"; + return $"'{PythonTypeName(self)}' object has no attribute '{name}'"; + } + + private static string PythonTypeName(PyObject self) + { try { - return message + GetSuggestionHint(self.Reference, name); + using var pyType = self.GetPythonType(); + return pyType.Name; } catch { - // never let suggestion building turn into a different exception - return message; + return "object"; + } + } + + /// + /// Resolves the managed whose members should be searched for a + /// missing-attribute suggestion, and whether the access was on the type object itself + /// ( = true, for static members and enum values) rather + /// than on an instance. Returns false for objects that are not reflected .NET types. + /// + private static bool TryGetSuggestionTarget(BorrowedReference ob, out Type? type, out bool staticScope) + { + type = null; + staticScope = false; + switch (GetManagedObject(ob)) + { + case CLRObject clrObj when clrObj.inst is not null: + type = clrObj.inst.GetType(); + return true; + case ClassBase classBase when classBase.type.Valid: + type = classBase.type.Value; + staticScope = true; + return true; + default: + return false; } } @@ -707,19 +742,27 @@ internal static string BuildMissingAttributeMessage(PyObject self, string name) /// private static string GetSuggestionHint(BorrowedReference ob, string name) { - if (string.IsNullOrEmpty(name) || name.StartsWith("__", StringComparison.Ordinal)) + if (!TryGetSuggestionTarget(ob, out var type, out _)) { return string.Empty; } - if (GetManagedObject(ob) is not CLRObject clrObj || clrObj.inst is null) + return GetSuggestionHint(type!, name); + } + + private static string GetSuggestionHint(Type type, string name) + { + if (string.IsNullOrEmpty(name) || name.StartsWith("__", StringComparison.Ordinal)) { return string.Empty; } - // The hint is built and cached once per (type, name); on a repeated miss this is - // just a dictionary lookup. An empty string means there was nothing to suggest. - return _suggestionCache.GetOrAdd((clrObj.inst.GetType(), name), + // The hint is built and cached once per (type, name); on a repeated miss this is just + // a dictionary lookup. An empty string means there was nothing to suggest. The + // suggested names use the same snake_case convention Python exposes members under + // (see ToSnakeCaseMemberName), so they are independent of whether the access was on + // an instance or the type object. + return _suggestionCache.GetOrAdd((type, name), static key => ComputeSimilarMemberNames(key.Type, key.Name)); } @@ -742,8 +785,12 @@ private static string GetErrorMessage(BorrowedReference value, string fallbackNa return $"object has no attribute '{fallbackName}'"; } - // The deduplicated snake_case member names of a type, cached so the reflection and - // string conversion happen at most once per type rather than on every attribute miss. + // The snake_case candidate member names of a type, cached so the reflection and name + // conversion happen at most once per type rather than on every attribute miss. Instance + // and static members are both included, and each is converted with ToSnakeCaseMemberName + // so the suggestion matches the name Python exposes it under: methods become lower_snake, + // while enum values, consts and static-readonly members become UPPER_SNAKE (e.g. + // DayOfWeek.SUNDAY, Math.PI, String.EMPTY). private static HashSet GetCandidateMemberNames(Type type) { return _candidateNameCache.GetOrAdd(type, static t => @@ -766,8 +813,6 @@ private static HashSet GetCandidateMemberNames(Type type) continue; } - // Suggest the snake_case alias, since that is the fork's PEP8-style - // public API surface (members are exposed in both Pascal and snake case). names.Add(ToSnakeCaseMemberName(member)); } diff --git a/src/runtime/Types/MetaType.cs b/src/runtime/Types/MetaType.cs index 9a66240d3..36a1a4b40 100644 --- a/src/runtime/Types/MetaType.cs +++ b/src/runtime/Types/MetaType.cs @@ -26,6 +26,13 @@ internal sealed class MetaType : ManagedType "__subclasscheck__", }; + /// + /// The CLR metatype object. Reflected .NET types are instances of it, so wiring the + /// AttributeError miss hook here enriches misses on a type object's own attributes + /// (static members and enum values). + /// + internal static BorrowedReference ClrMetaTypeReference => PyCLRMetaType.Reference; + /// /// Metatype initialization. This bootstraps the CLR metatype to life. /// diff --git a/tests/test_class.py b/tests/test_class.py index 2f9f57778..7bdaa65c4 100644 --- a/tests/test_class.py +++ b/tests/test_class.py @@ -123,6 +123,66 @@ def test_missing_attribute_hasattr_still_false(): assert hasattr(s, "Length") +def test_missing_static_method_suggests_similar(): + """A mistyped static method on a type object suggests the similar member.""" + from System import Math + + with pytest.raises(AttributeError) as exc_info: + _ = Math.Sqrtt + + message = str(exc_info.value) + assert "type object 'Math'" in message + assert "Sqrtt" in message + assert "Did you mean" in message + # Methods are exposed lower_snake, so the suggestion is 'sqrt'. Quoted so the assertion + # matches the suggestion, not the typo 'Sqrtt'. + assert "'sqrt'" in message + + +def test_missing_static_const_suggests_similar(): + """A mistyped static const (Math.PI) suggests the UPPER_SNAKE constant name.""" + from System import Math + + with pytest.raises(AttributeError) as exc_info: + _ = Math.PII + + message = str(exc_info.value) + assert "Did you mean" in message + # Consts are exposed UPPER_SNAKE; quoted so it matches the suggestion, not the typo 'PII'. + assert "'PI'" in message + + +def test_missing_static_field_suggests_similar(): + """A mistyped static-readonly field (String.Empty) suggests the UPPER_SNAKE name.""" + with pytest.raises(AttributeError) as exc_info: + _ = System.String.Empy + + message = str(exc_info.value) + assert "Did you mean" in message + # static-readonly fields are exposed UPPER_SNAKE -> String.EMPTY. + assert "'EMPTY'" in message + + +def test_missing_static_member_no_similar(): + """A static member with no similar name keeps the standard message (no hint).""" + from System import Math + + with pytest.raises(AttributeError) as exc_info: + _ = Math.Zzzzzz + + message = str(exc_info.value) + assert "Zzzzzz" in message + assert "Did you mean" not in message + + +def test_missing_static_member_hasattr_still_false(): + """The type-object miss hook must not break hasattr() on a type.""" + from System import Math + + assert hasattr(Math, "Sqrt") + assert not hasattr(Math, "Sqrtt") + + def test_missing_attribute_hook_is_native(): """The __getattr__ hook must be a native method descriptor. diff --git a/tests/test_enum.py b/tests/test_enum.py index f7cff4a7e..4c15a431e 100644 --- a/tests/test_enum.py +++ b/tests/test_enum.py @@ -31,6 +31,43 @@ def test_enum_get_member(): assert DayOfWeek.Saturday == DayOfWeek(6) +def test_missing_enum_member_suggests_similar(): + """A mistyped enum member suggests the correct member by its .NET name.""" + from System import DayOfWeek + + with pytest.raises(AttributeError) as exc_info: + _ = DayOfWeek.Sundey + + message = str(exc_info.value) + # Access on the type object itself uses the "type object 'T'" wording. + assert "type object 'DayOfWeek'" in message + assert "Sundey" in message + assert "Did you mean" in message + # Enum values are exposed in UPPER_SNAKE (the fork's PEP8 constant convention), so that is + # the form suggested -- DayOfWeek.SUNDAY, not 'Sunday'. + assert "'SUNDAY'" in message + + +def test_missing_enum_member_no_similar(): + """An enum member with no similar name keeps the standard message (no hint).""" + from System import DayOfWeek + + with pytest.raises(AttributeError) as exc_info: + _ = DayOfWeek.Xyzzy + + message = str(exc_info.value) + assert "Xyzzy" in message + assert "Did you mean" not in message + + +def test_missing_enum_member_hasattr_still_false(): + """Enriching the AttributeError must not break hasattr() on enum types.""" + from System import DayOfWeek + + assert hasattr(DayOfWeek, "Sunday") + assert not hasattr(DayOfWeek, "Sundey") + + def test_byte_enum(): """Test byte enum.""" assert Test.ByteEnum.Zero == Test.ByteEnum(0) From 316e2af7cd12a09b8a6d70d1d9f5289cd6e0d1a2 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 7 Jul 2026 10:50:00 -0400 Subject: [PATCH 5/5] Update version to 2.0.58 Bump the package , AssemblyVersion/AssemblyFileVersion and the perf-test baseline reference to 2.0.58 for the AttributeError suggestion caching and the static/enum suggestion extension in this PR. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/perf_tests/Python.PerformanceTests.csproj | 4 ++-- src/runtime/Properties/AssemblyInfo.cs | 4 ++-- src/runtime/Python.Runtime.csproj | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/perf_tests/Python.PerformanceTests.csproj b/src/perf_tests/Python.PerformanceTests.csproj index 24c4793a1..fc92a4851 100644 --- a/src/perf_tests/Python.PerformanceTests.csproj +++ b/src/perf_tests/Python.PerformanceTests.csproj @@ -13,7 +13,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + compile @@ -25,7 +25,7 @@ - + diff --git a/src/runtime/Properties/AssemblyInfo.cs b/src/runtime/Properties/AssemblyInfo.cs index 2a7596eeb..bbd75b3db 100644 --- a/src/runtime/Properties/AssemblyInfo.cs +++ b/src/runtime/Properties/AssemblyInfo.cs @@ -4,5 +4,5 @@ [assembly: InternalsVisibleTo("Python.EmbeddingTest, PublicKey=00240000048000009400000006020000002400005253413100040000110000005ffd8f49fb44ab0641b3fd8d55e749f716e6dd901032295db641eb98ee46063cbe0d4a1d121ef0bc2af95f8a7438d7a80a3531316e6b75c2dae92fb05a99f03bf7e0c03980e1c3cfb74ba690aca2f3339ef329313bcc5dccced125a4ffdc4531dcef914602cd5878dc5fbb4d4c73ddfbc133f840231343e013762884d6143189")] [assembly: InternalsVisibleTo("Python.Test, PublicKey=00240000048000009400000006020000002400005253413100040000110000005ffd8f49fb44ab0641b3fd8d55e749f716e6dd901032295db641eb98ee46063cbe0d4a1d121ef0bc2af95f8a7438d7a80a3531316e6b75c2dae92fb05a99f03bf7e0c03980e1c3cfb74ba690aca2f3339ef329313bcc5dccced125a4ffdc4531dcef914602cd5878dc5fbb4d4c73ddfbc133f840231343e013762884d6143189")] -[assembly: AssemblyVersion("2.0.57")] -[assembly: AssemblyFileVersion("2.0.57")] +[assembly: AssemblyVersion("2.0.58")] +[assembly: AssemblyFileVersion("2.0.58")] diff --git a/src/runtime/Python.Runtime.csproj b/src/runtime/Python.Runtime.csproj index 43988bbf0..9f0476cf7 100644 --- a/src/runtime/Python.Runtime.csproj +++ b/src/runtime/Python.Runtime.csproj @@ -5,7 +5,7 @@ Python.Runtime Python.Runtime QuantConnect.pythonnet - 2.0.57 + 2.0.58 false LICENSE https://github.com/pythonnet/pythonnet