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
4 changes: 2 additions & 2 deletions src/perf_tests/Python.PerformanceTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.*" />
<PackageReference Include="quantconnect.pythonnet" Version="2.0.57" GeneratePathProperty="true">
<PackageReference Include="quantconnect.pythonnet" Version="2.0.58" GeneratePathProperty="true">
<IncludeAssets>compile</IncludeAssets>
</PackageReference>
</ItemGroup>
Expand All @@ -25,7 +25,7 @@
</Target>

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

<Target Name="CopyNewBuild" AfterTargets="Build">
Expand Down
18 changes: 17 additions & 1 deletion src/runtime/AttributeErrorHint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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)
{
Expand All @@ -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)
{
Expand All @@ -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;
}
Expand Down Expand Up @@ -158,6 +173,7 @@ internal static void Shutdown()
// are reused by the next Initialize.
_hookSlot = IntPtr.Zero;
_genericGetAttr = IntPtr.Zero;
_typeGetAttro = IntPtr.Zero;
}
}
}
4 changes: 2 additions & 2 deletions src/runtime/Properties/AssemblyInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
2 changes: 1 addition & 1 deletion src/runtime/Python.Runtime.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<RootNamespace>Python.Runtime</RootNamespace>
<AssemblyName>Python.Runtime</AssemblyName>
<PackageId>QuantConnect.pythonnet</PackageId>
<Version>2.0.57</Version>
<Version>2.0.58</Version>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<RepositoryUrl>https://github.com/pythonnet/pythonnet</RepositoryUrl>
Expand Down
161 changes: 117 additions & 44 deletions src/runtime/Types/ClassBase.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
Expand Down Expand Up @@ -28,6 +29,18 @@ internal class ClassBase : ManagedType, IDeserializationCallback
internal readonly Dictionary<int, MethodObject> 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<Type, HashSet<string>> _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, 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)
{
if (tp is null) throw new ArgumentNullException(nameof(type));
Expand Down Expand Up @@ -663,26 +676,61 @@ internal static void AppendAttributeErrorSuggestions(BorrowedReference ob, Borro
/// </summary>
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";
}
}

/// <summary>
/// Resolves the managed <see cref="Type"/> whose members should be searched for a
/// missing-attribute suggestion, and whether the access was on the type object itself
/// (<paramref name="staticScope"/> = true, for static members and enum values) rather
/// than on an instance. Returns false for objects that are not reflected .NET types.
/// </summary>
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;
}
}

Expand All @@ -694,23 +742,28 @@ internal static string BuildMissingAttributeMessage(PyObject self, string name)
/// </summary>
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 string.Empty;
}
return GetSuggestionHint(type!, name);
}

var suggestions = GetSimilarMemberNames(clrObj.inst.GetType(), name);
if (suggestions.Count == 0)
private static string GetSuggestionHint(Type type, string name)
{
if (string.IsNullOrEmpty(name) || name.StartsWith("__", StringComparison.Ordinal))
{
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. 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));
}

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

private static List<string> GetSimilarMemberNames(Type type, string name)
// 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<string> GetCandidateMemberNames(Type type)
{
const int MaxSuggestions = 5;
var threshold = Math.Max(2, name.Length / 3);

var seen = new HashSet<string>(StringComparer.Ordinal);
var scored = new List<(string Name, int Distance)>();

var members = type.GetMembers(BindingFlags.Public | BindingFlags.Instance
| BindingFlags.Static | BindingFlags.FlattenHierarchy);
foreach (var member in members)
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 names = new HashSet<string>(StringComparer.Ordinal);

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;
}

names.Add(ToSnakeCaseMemberName(member));
}

return names;
});
}

// 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);

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
Expand All @@ -774,12 +841,18 @@ private static List<string> GetSimilarMemberNames(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)
.ToList();
.Select(t => $"'{t.Name}'");

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

private static string ToSnakeCaseMemberName(MemberInfo member)
Expand Down
7 changes: 7 additions & 0 deletions src/runtime/Types/MetaType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ internal sealed class MetaType : ManagedType
"__subclasscheck__",
};

/// <summary>
/// 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).
/// </summary>
internal static BorrowedReference ClrMetaTypeReference => PyCLRMetaType.Reference;

/// <summary>
/// Metatype initialization. This bootstraps the CLR metatype to life.
/// </summary>
Expand Down
Loading
Loading