Skip to content

Commit 65b70f3

Browse files
jhonabreulclaude
andauthored
Cache and extend AttributeError member-name suggestions (static members and enum values) (#130)
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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> * Update version to 2.0.58 Bump the package <Version>, 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8b60b4e commit 65b70f3

8 files changed

Lines changed: 262 additions & 50 deletions

File tree

src/perf_tests/Python.PerformanceTests.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
1414
</PackageReference>
1515
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.*" />
16-
<PackageReference Include="quantconnect.pythonnet" Version="2.0.57" GeneratePathProperty="true">
16+
<PackageReference Include="quantconnect.pythonnet" Version="2.0.58" GeneratePathProperty="true">
1717
<IncludeAssets>compile</IncludeAssets>
1818
</PackageReference>
1919
</ItemGroup>
@@ -25,7 +25,7 @@
2525
</Target>
2626

2727
<Target Name="CopyBaseline" AfterTargets="Build">
28-
<Copy SourceFiles="$(NuGetPackageRoot)quantconnect.pythonnet\2.0.57\lib\net10.0\Python.Runtime.dll" DestinationFolder="$(OutDir)baseline" />
28+
<Copy SourceFiles="$(NuGetPackageRoot)quantconnect.pythonnet\2.0.58\lib\net10.0\Python.Runtime.dll" DestinationFolder="$(OutDir)baseline" />
2929
</Target>
3030

3131
<Target Name="CopyNewBuild" AfterTargets="Build">

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/Properties/AssemblyInfo.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@
44
[assembly: InternalsVisibleTo("Python.EmbeddingTest, PublicKey=00240000048000009400000006020000002400005253413100040000110000005ffd8f49fb44ab0641b3fd8d55e749f716e6dd901032295db641eb98ee46063cbe0d4a1d121ef0bc2af95f8a7438d7a80a3531316e6b75c2dae92fb05a99f03bf7e0c03980e1c3cfb74ba690aca2f3339ef329313bcc5dccced125a4ffdc4531dcef914602cd5878dc5fbb4d4c73ddfbc133f840231343e013762884d6143189")]
55
[assembly: InternalsVisibleTo("Python.Test, PublicKey=00240000048000009400000006020000002400005253413100040000110000005ffd8f49fb44ab0641b3fd8d55e749f716e6dd901032295db641eb98ee46063cbe0d4a1d121ef0bc2af95f8a7438d7a80a3531316e6b75c2dae92fb05a99f03bf7e0c03980e1c3cfb74ba690aca2f3339ef329313bcc5dccced125a4ffdc4531dcef914602cd5878dc5fbb4d4c73ddfbc133f840231343e013762884d6143189")]
66

7-
[assembly: AssemblyVersion("2.0.57")]
8-
[assembly: AssemblyFileVersion("2.0.57")]
7+
[assembly: AssemblyVersion("2.0.58")]
8+
[assembly: AssemblyFileVersion("2.0.58")]

src/runtime/Python.Runtime.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<RootNamespace>Python.Runtime</RootNamespace>
66
<AssemblyName>Python.Runtime</AssemblyName>
77
<PackageId>QuantConnect.pythonnet</PackageId>
8-
<Version>2.0.57</Version>
8+
<Version>2.0.58</Version>
99
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
1010
<PackageLicenseFile>LICENSE</PackageLicenseFile>
1111
<RepositoryUrl>https://github.com/pythonnet/pythonnet</RepositoryUrl>

src/runtime/Types/ClassBase.cs

Lines changed: 117 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Collections;
3+
using System.Collections.Concurrent;
34
using System.Collections.Generic;
45
using System.Diagnostics;
56
using System.Linq;
@@ -28,6 +29,18 @@ internal class ClassBase : ManagedType, IDeserializationCallback
2829
internal readonly Dictionary<int, MethodObject> richcompare = new();
2930
internal MaybeType type;
3031

32+
// Reflecting over a managed type's full member set (with FlattenHierarchy) plus the
33+
// snake_case conversion is expensive, and the result never changes for a given type.
34+
// Compute it once per type.
35+
private static readonly ConcurrentDictionary<Type, HashSet<string>> _candidateNameCache = new();
36+
37+
// 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, 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.
42+
private static readonly ConcurrentDictionary<(Type Type, string Name), string> _suggestionCache = new();
43+
3144
internal ClassBase(Type tp)
3245
{
3346
if (tp is null) throw new ArgumentNullException(nameof(type));
@@ -663,26 +676,61 @@ internal static void AppendAttributeErrorSuggestions(BorrowedReference ob, Borro
663676
/// </summary>
664677
internal static string BuildMissingAttributeMessage(PyObject self, string name)
665678
{
666-
var typeName = "object";
667679
try
668680
{
669-
using var pyType = self.GetPythonType();
670-
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+
}
671691
}
672692
catch
673693
{
674-
// fall back to the generic type name
694+
// never let message building turn into a different exception
675695
}
676696

677-
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+
{
678702
try
679703
{
680-
return message + GetSuggestionHint(self.Reference, name);
704+
using var pyType = self.GetPythonType();
705+
return pyType.Name;
681706
}
682707
catch
683708
{
684-
// never let suggestion building turn into a different exception
685-
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;
686734
}
687735
}
688736

@@ -694,23 +742,28 @@ internal static string BuildMissingAttributeMessage(PyObject self, string name)
694742
/// </summary>
695743
private static string GetSuggestionHint(BorrowedReference ob, string name)
696744
{
697-
if (string.IsNullOrEmpty(name) || name.StartsWith("__", StringComparison.Ordinal))
745+
if (!TryGetSuggestionTarget(ob, out var type, out _))
698746
{
699747
return string.Empty;
700748
}
701749

702-
if (GetManagedObject(ob) is not CLRObject clrObj || clrObj.inst is null)
703-
{
704-
return string.Empty;
705-
}
750+
return GetSuggestionHint(type!, name);
751+
}
706752

707-
var suggestions = GetSimilarMemberNames(clrObj.inst.GetType(), name);
708-
if (suggestions.Count == 0)
753+
private static string GetSuggestionHint(Type type, string name)
754+
{
755+
if (string.IsNullOrEmpty(name) || name.StartsWith("__", StringComparison.Ordinal))
709756
{
710757
return string.Empty;
711758
}
712759

713-
return " Did you mean: " + string.Join(", ", suggestions.Select(s => $"'{s}'")) + "?";
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),
766+
static key => ComputeSimilarMemberNames(key.Type, key.Name));
714767
}
715768

716769
private static string GetErrorMessage(BorrowedReference value, string fallbackName)
@@ -732,38 +785,52 @@ private static string GetErrorMessage(BorrowedReference value, string fallbackNa
732785
return $"object has no attribute '{fallbackName}'";
733786
}
734787

735-
private static List<string> GetSimilarMemberNames(Type type, string name)
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).
794+
private static HashSet<string> GetCandidateMemberNames(Type type)
736795
{
737-
const int MaxSuggestions = 5;
738-
var threshold = Math.Max(2, name.Length / 3);
739-
740-
var seen = new HashSet<string>(StringComparer.Ordinal);
741-
var scored = new List<(string Name, int Distance)>();
742-
743-
var members = type.GetMembers(BindingFlags.Public | BindingFlags.Instance
744-
| BindingFlags.Static | BindingFlags.FlattenHierarchy);
745-
foreach (var member in members)
796+
return _candidateNameCache.GetOrAdd(type, static t =>
746797
{
747-
// Skip property/event accessors, operators and other special-name methods,
748-
// as well as compiler-generated members; none are accessible by name.
749-
if (member is MethodBase { IsSpecialName: true })
750-
{
751-
continue;
752-
}
798+
var names = new HashSet<string>(StringComparer.Ordinal);
753799

754-
if (member.Name.Length == 0 || member.Name[0] == '<')
800+
var members = t.GetMembers(BindingFlags.Public | BindingFlags.Instance
801+
| BindingFlags.Static | BindingFlags.FlattenHierarchy);
802+
foreach (var member in members)
755803
{
756-
continue;
757-
}
804+
// Skip property/event accessors, operators and other special-name methods,
805+
// as well as compiler-generated members; none are accessible by name.
806+
if (member is MethodBase { IsSpecialName: true })
807+
{
808+
continue;
809+
}
758810

759-
// Suggest the snake_case alias, since that is the fork's PEP8-style
760-
// public API surface (members are exposed in both Pascal and snake case).
761-
var candidate = ToSnakeCaseMemberName(member);
762-
if (!seen.Add(candidate))
763-
{
764-
continue;
811+
if (member.Name.Length == 0 || member.Name[0] == '<')
812+
{
813+
continue;
814+
}
815+
816+
names.Add(ToSnakeCaseMemberName(member));
765817
}
766818

819+
return names;
820+
});
821+
}
822+
823+
// Builds the " Did you mean: 'x', 'y'?" hint for a missing attribute, or an empty
824+
// string when no member is similar enough to suggest. The result is cached in
825+
// _suggestionCache, so this runs at most once per (type, missing-name).
826+
private static string ComputeSimilarMemberNames(Type type, string name)
827+
{
828+
const int MaxSuggestions = 5;
829+
var threshold = Math.Max(2, name.Length / 3);
830+
831+
var scored = new List<(string Name, int Distance)>();
832+
foreach (var candidate in GetCandidateMemberNames(type))
833+
{
767834
var distance = LevenshteinDistance(name, candidate);
768835
var related = distance <= threshold
769836
|| candidate.IndexOf(name, StringComparison.OrdinalIgnoreCase) >= 0
@@ -774,12 +841,18 @@ private static List<string> GetSimilarMemberNames(Type type, string name)
774841
}
775842
}
776843

777-
return scored
844+
if (scored.Count == 0)
845+
{
846+
return string.Empty;
847+
}
848+
849+
var suggestions = scored
778850
.OrderBy(t => t.Distance)
779851
.ThenBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
780852
.Take(MaxSuggestions)
781-
.Select(t => t.Name)
782-
.ToList();
853+
.Select(t => $"'{t.Name}'");
854+
855+
return " Did you mean: " + string.Join(", ", suggestions) + "?";
783856
}
784857

785858
private static string ToSnakeCaseMemberName(MemberInfo member)

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>

0 commit comments

Comments
 (0)