From 6e9de1f2edf92b5af24909326261c28e66e4b21b Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 30 Jun 2026 13:52:29 -0400 Subject: [PATCH 1/5] Suggest similar member names on AttributeError for .NET objects When accessing a missing attribute on a .NET object from Python, enrich the resulting AttributeError message with a "Did you mean ...?" hint listing similarly-named members of the managed type. The work is done inside tp_getattro where the object and attribute name are available directly, so it does not depend on the AttributeError .name/.obj attributes (Python 3.10+) and works across all supported Python versions (3.7-3.11). It only runs on the exceptional miss path, keeping normal attribute access untouched, and always re-raises an AttributeError so hasattr()/getattr(default) keep working. - ClassObject gets a tp_getattro that delegates to PyObject_GenericGetAttr and appends suggestions on a miss (inherited by EnumObject, LookUpObject, ExceptionClassObject, ClassDerivedObject). - DynamicClassObject appends suggestions on its RuntimeBinder miss path. - Shared helpers live in ClassBase (Levenshtein-based ranking, dunder names skipped, original CPython message preserved). Co-Authored-By: Claude Opus 4.8 --- src/embed_tests/TestPropertyAccess.cs | 36 +++++++ src/runtime/Types/ClassBase.cs | 137 ++++++++++++++++++++++++ src/runtime/Types/ClassObject.cs | 15 +++ src/runtime/Types/DynamicClassObject.cs | 5 +- tests/test_class.py | 34 ++++++ 5 files changed, 226 insertions(+), 1 deletion(-) diff --git a/src/embed_tests/TestPropertyAccess.cs b/src/embed_tests/TestPropertyAccess.cs index 8dba383d6..7cd4ba266 100644 --- a/src/embed_tests/TestPropertyAccess.cs +++ b/src/embed_tests/TestPropertyAccess.cs @@ -1129,6 +1129,42 @@ def GetValue(self, fixture): } } + [Test] + public void TestGetMisspelledDynamicObjectPropertySuggestsSimilarMembers() + { + dynamic model = PyModule.FromString("module", @" +from clr import AddReference +AddReference(""Python.EmbeddingTest"") +AddReference(""System"") + +from Python.EmbeddingTest import * + +class TestGetMisspelledDynamicObjectPropertySuggestsSimilarMembers: + def GetValue(self, fixture): + try: + # 'NonDynamicPropertyy' is a near miss of the real 'NonDynamicProperty' member. + prop = fixture.NonDynamicPropertyy + except AttributeError as e: + return e + + return None +").GetAttr("TestGetMisspelledDynamicObjectPropertySuggestsSimilarMembers").Invoke(); + + dynamic fixture = new DynamicFixture(); + + using (Py.GIL()) + { + var result = model.GetValue(fixture) as PyObject; + Assert.IsFalse(result.IsNone()); + Assert.AreEqual(result.PyType, Exceptions.AttributeError); + + var message = result.ToString(); + Assert.That(message, Does.Contain("NonDynamicPropertyy")); + Assert.That(message, Does.Contain("Did you mean")); + Assert.That(message, Does.Contain("NonDynamicProperty")); + } + } + public class CSharpTestClass { public string CSharpProperty { get; set; } diff --git a/src/runtime/Types/ClassBase.cs b/src/runtime/Types/ClassBase.cs index 590c870b5..caa3dfb99 100644 --- a/src/runtime/Types/ClassBase.cs +++ b/src/runtime/Types/ClassBase.cs @@ -611,5 +611,142 @@ protected virtual void OnDeserialization(object sender) } void IDeserializationCallback.OnDeserialization(object sender) => this.OnDeserialization(sender); + + /// + /// If an AttributeError is currently set as the result of a missing + /// attribute lookup on a .NET object, rewrites its message to append a list + /// of similarly-named members of the managed type (a "Did you mean ...?" hint). + /// This is a no-op when there is no AttributeError set, when the object is not + /// a CLR object, or when no similarly-named members exist. It only runs on the + /// exceptional (miss) path, so the reflection cost is not on the hot path. + /// + internal static void AppendAttributeErrorSuggestions(BorrowedReference ob, BorrowedReference key) + { + if (!Exceptions.ExceptionMatches(Exceptions.AttributeError)) + { + return; + } + + string? name = Runtime.GetManagedString(key); + // Skip empty and dunder names: the latter are probed internally by CPython + // (e.g. __iter__, __len__) and are never user-facing typos worth helping with. + if (string.IsNullOrEmpty(name) || name!.StartsWith("__", StringComparison.Ordinal)) + { + return; + } + + if (GetManagedObject(ob) is not CLRObject clrObj || clrObj.inst is null) + { + return; + } + + var suggestions = GetSimilarMemberNames(clrObj.inst.GetType(), name); + if (suggestions.Count == 0) + { + return; + } + + // Keep the original AttributeError message and append our hint to it. + Runtime.PyErr_Fetch(out var errType, out var errValue, out var errTraceback); + try + { + string baseMessage = GetErrorMessage(errValue.BorrowNullable(), name); + string hint = " Did you mean: " + string.Join(", ", suggestions.Select(s => $"'{s}'")) + "?"; + Exceptions.SetError(Exceptions.AttributeError, baseMessage + hint); + } + finally + { + errType.Dispose(); + errValue.Dispose(); + errTraceback.Dispose(); + } + } + + private static string GetErrorMessage(BorrowedReference value, string fallbackName) + { + if (value != null) + { + using var str = Runtime.PyObject_Str(value); + if (!str.IsNull()) + { + string? managed = Runtime.GetManagedString(str.Borrow()); + if (!string.IsNullOrEmpty(managed)) + { + return managed!; + } + } + // PyObject_Str may itself have failed; do not let that error leak out. + Exceptions.Clear(); + } + return $"object has no attribute '{fallbackName}'"; + } + + private static List GetSimilarMemberNames(Type type, string name) + { + const int MaxSuggestions = 5; + int threshold = Math.Max(2, name.Length / 3); + + var seen = new HashSet(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) + { + // 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; + } + + string candidate = member.Name; + if (candidate.Length == 0 || candidate[0] == '<' || !seen.Add(candidate)) + { + continue; + } + + int distance = LevenshteinDistance(name, candidate); + bool related = distance <= threshold + || candidate.IndexOf(name, StringComparison.OrdinalIgnoreCase) >= 0 + || name.IndexOf(candidate, StringComparison.OrdinalIgnoreCase) >= 0; + if (related) + { + scored.Add((candidate, distance)); + } + } + + return scored + .OrderBy(t => t.Distance) + .ThenBy(t => t.Name, StringComparer.OrdinalIgnoreCase) + .Take(MaxSuggestions) + .Select(t => t.Name) + .ToList(); + } + + private static int LevenshteinDistance(string a, string b) + { + a = a.ToLowerInvariant(); + b = b.ToLowerInvariant(); + int n = a.Length, m = b.Length; + if (n == 0) return m; + if (m == 0) return n; + + var prev = new int[m + 1]; + var curr = new int[m + 1]; + for (int j = 0; j <= m; j++) prev[j] = j; + + for (int i = 1; i <= n; i++) + { + curr[0] = i; + for (int j = 1; j <= m; j++) + { + int cost = a[i - 1] == b[j - 1] ? 0 : 1; + curr[j] = Math.Min(Math.Min(curr[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost); + } + (prev, curr) = (curr, prev); + } + return prev[m]; + } } } diff --git a/src/runtime/Types/ClassObject.cs b/src/runtime/Types/ClassObject.cs index b57378a32..48a975898 100644 --- a/src/runtime/Types/ClassObject.cs +++ b/src/runtime/Types/ClassObject.cs @@ -167,6 +167,21 @@ public override void InitializeSlots(BorrowedReference pyType, SlotsHolder slots protected virtual NewReference NewObjectToPython(object obj, BorrowedReference tp) => CLRObject.GetReference(obj, tp); + /// + /// Type __getattro__ implementation. Delegates to the generic CLR attribute + /// lookup, but enriches the AttributeError raised for a missing attribute with + /// suggestions of similarly-named members of the managed type. + /// + public static NewReference tp_getattro(BorrowedReference ob, BorrowedReference key) + { + var result = Runtime.PyObject_GenericGetAttr(ob, key); + if (result.IsNull()) + { + AppendAttributeErrorSuggestions(ob, key); + } + return result; + } + private static NewReference NewEnum(Type type, BorrowedReference args, BorrowedReference tp) { nint argCount = Runtime.PyTuple_Size(args); diff --git a/src/runtime/Types/DynamicClassObject.cs b/src/runtime/Types/DynamicClassObject.cs index cb6fd5650..621a6f423 100644 --- a/src/runtime/Types/DynamicClassObject.cs +++ b/src/runtime/Types/DynamicClassObject.cs @@ -80,7 +80,10 @@ public static NewReference tp_getattro(BorrowedReference ob, BorrowedReference k } catch (RuntimeBinder.RuntimeBinderException) { - // Do nothing, AttributeError was already raised in Python side and it was not cleared. + // The attribute is neither a static member nor a dynamic property. + // AttributeError was already raised in Python side (by the generic + // getattr above) and was not cleared; enrich it with member suggestions. + AppendAttributeErrorSuggestions(ob, key); } // Catch C# exceptions and raise them as Python exceptions. catch (Exception exception) diff --git a/tests/test_class.py b/tests/test_class.py index ec275d752..4fc86ae5d 100644 --- a/tests/test_class.py +++ b/tests/test_class.py @@ -66,6 +66,40 @@ def test_non_exported(): _ = Test.NonExportable +def test_missing_attribute_suggests_similar_members(): + """A missing attribute on a .NET object should suggest similarly-named members.""" + s = System.String("this is a test") + + # 'Lenght' is a transposition of 'Length' (a real member), so it should be suggested. + with pytest.raises(AttributeError) as exc_info: + _ = s.Lenght + + message = str(exc_info.value) + assert "Lenght" in message + assert "Did you mean" in message + assert "Length" in message + + +def test_missing_attribute_no_similar_members(): + """A missing attribute with no similar members keeps the standard message.""" + s = System.String("this is a test") + + with pytest.raises(AttributeError) as exc_info: + _ = s.completely_unrelated_xyzzy + + message = str(exc_info.value) + assert "completely_unrelated_xyzzy" in message + assert "Did you mean" not in message + + +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") + + assert not hasattr(s, "Lenght") + assert hasattr(s, "Length") + + def test_basic_subclass(): """Test basic subclass of a managed class.""" from System.Collections import Hashtable From ab37ad38104b3dfd52077ad2da20da93baccf630 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 30 Jun 2026 15:00:52 -0400 Subject: [PATCH 2/5] Emit AttributeError member suggestions in snake_case The fork exposes .NET members under PEP8-style snake_case aliases, so the "Did you mean ...?" suggestions now use that form (e.g. 'length' instead of 'Length'). Conversion reuses the existing ToSnakeCase helpers, so const and static-readonly members are rendered UPPER_CASE to match how they are exposed to Python. Co-Authored-By: Claude Opus 4.8 --- src/embed_tests/TestPropertyAccess.cs | 10 ++++++---- src/runtime/Types/ClassBase.cs | 23 +++++++++++++++++++++-- tests/test_class.py | 14 +++++++++----- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/embed_tests/TestPropertyAccess.cs b/src/embed_tests/TestPropertyAccess.cs index 7cd4ba266..1c9d0e7fd 100644 --- a/src/embed_tests/TestPropertyAccess.cs +++ b/src/embed_tests/TestPropertyAccess.cs @@ -1142,8 +1142,9 @@ from Python.EmbeddingTest import * class TestGetMisspelledDynamicObjectPropertySuggestsSimilarMembers: def GetValue(self, fixture): try: - # 'NonDynamicPropertyy' is a near miss of the real 'NonDynamicProperty' member. - prop = fixture.NonDynamicPropertyy + # 'non_dynamic_propertyy' is a near miss of the snake_case alias of the + # real 'NonDynamicProperty' member. + prop = fixture.non_dynamic_propertyy except AttributeError as e: return e @@ -1158,10 +1159,11 @@ def GetValue(self, fixture): Assert.IsFalse(result.IsNone()); Assert.AreEqual(result.PyType, Exceptions.AttributeError); + // Suggestions are emitted in snake_case, matching the fork's PEP8-style API. var message = result.ToString(); - Assert.That(message, Does.Contain("NonDynamicPropertyy")); + Assert.That(message, Does.Contain("non_dynamic_propertyy")); Assert.That(message, Does.Contain("Did you mean")); - Assert.That(message, Does.Contain("NonDynamicProperty")); + Assert.That(message, Does.Contain("non_dynamic_property")); } } diff --git a/src/runtime/Types/ClassBase.cs b/src/runtime/Types/ClassBase.cs index caa3dfb99..d9cda0fbf 100644 --- a/src/runtime/Types/ClassBase.cs +++ b/src/runtime/Types/ClassBase.cs @@ -700,8 +700,15 @@ private static List GetSimilarMemberNames(Type type, string name) continue; } - string candidate = member.Name; - if (candidate.Length == 0 || candidate[0] == '<' || !seen.Add(candidate)) + 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). + string candidate = ToSnakeCaseMemberName(member); + if (!seen.Add(candidate)) { continue; } @@ -724,6 +731,18 @@ private static List GetSimilarMemberNames(Type type, string name) .ToList(); } + private static string ToSnakeCaseMemberName(MemberInfo member) + { + // Use the field/property overloads so const and static-readonly members + // are converted to UPPER_CASE, matching how they are exposed to Python. + return member switch + { + FieldInfo fieldInfo => fieldInfo.ToSnakeCase(), + PropertyInfo propertyInfo => propertyInfo.ToSnakeCase(), + _ => member.Name.ToSnakeCase(), + }; + } + private static int LevenshteinDistance(string a, string b) { a = a.ToLowerInvariant(); diff --git a/tests/test_class.py b/tests/test_class.py index 4fc86ae5d..4f0effecd 100644 --- a/tests/test_class.py +++ b/tests/test_class.py @@ -67,17 +67,21 @@ def test_non_exported(): def test_missing_attribute_suggests_similar_members(): - """A missing attribute on a .NET object should suggest similarly-named members.""" + """A missing attribute on a .NET object should suggest similarly-named members. + + Suggestions are emitted in snake_case, matching the fork's PEP8-style API. + """ s = System.String("this is a test") - # 'Lenght' is a transposition of 'Length' (a real member), so it should be suggested. + # 'lenght' is a transposition of 'length' (the snake_case alias of the real + # 'Length' member), so it should be suggested. with pytest.raises(AttributeError) as exc_info: - _ = s.Lenght + _ = s.lenght message = str(exc_info.value) - assert "Lenght" in message + assert "lenght" in message assert "Did you mean" in message - assert "Length" in message + assert "length" in message def test_missing_attribute_no_similar_members(): From c9fabdce9ae18e5246c4e3bba1cd9bbc68e634cc Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 30 Jun 2026 15:09:07 -0400 Subject: [PATCH 3/5] Use var for local declarations in AttributeError suggestion helpers Style cleanup: prefer var over explicit types where the type is apparent from the right-hand side. Also drops two now-unnecessary null-forgiving operators that the compiler's flow analysis already proves non-null. Co-Authored-By: Claude Opus 4.8 --- src/runtime/Types/ClassBase.cs | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/runtime/Types/ClassBase.cs b/src/runtime/Types/ClassBase.cs index d9cda0fbf..617baae49 100644 --- a/src/runtime/Types/ClassBase.cs +++ b/src/runtime/Types/ClassBase.cs @@ -627,10 +627,10 @@ internal static void AppendAttributeErrorSuggestions(BorrowedReference ob, Borro return; } - string? name = Runtime.GetManagedString(key); + var name = Runtime.GetManagedString(key); // Skip empty and dunder names: the latter are probed internally by CPython // (e.g. __iter__, __len__) and are never user-facing typos worth helping with. - if (string.IsNullOrEmpty(name) || name!.StartsWith("__", StringComparison.Ordinal)) + if (string.IsNullOrEmpty(name) || name.StartsWith("__", StringComparison.Ordinal)) { return; } @@ -650,8 +650,8 @@ internal static void AppendAttributeErrorSuggestions(BorrowedReference ob, Borro Runtime.PyErr_Fetch(out var errType, out var errValue, out var errTraceback); try { - string baseMessage = GetErrorMessage(errValue.BorrowNullable(), name); - string hint = " Did you mean: " + string.Join(", ", suggestions.Select(s => $"'{s}'")) + "?"; + var baseMessage = GetErrorMessage(errValue.BorrowNullable(), name); + var hint = " Did you mean: " + string.Join(", ", suggestions.Select(s => $"'{s}'")) + "?"; Exceptions.SetError(Exceptions.AttributeError, baseMessage + hint); } finally @@ -669,10 +669,10 @@ private static string GetErrorMessage(BorrowedReference value, string fallbackNa using var str = Runtime.PyObject_Str(value); if (!str.IsNull()) { - string? managed = Runtime.GetManagedString(str.Borrow()); + var managed = Runtime.GetManagedString(str.Borrow()); if (!string.IsNullOrEmpty(managed)) { - return managed!; + return managed; } } // PyObject_Str may itself have failed; do not let that error leak out. @@ -684,7 +684,7 @@ private static string GetErrorMessage(BorrowedReference value, string fallbackNa private static List GetSimilarMemberNames(Type type, string name) { const int MaxSuggestions = 5; - int threshold = Math.Max(2, name.Length / 3); + var threshold = Math.Max(2, name.Length / 3); var seen = new HashSet(StringComparer.Ordinal); var scored = new List<(string Name, int Distance)>(); @@ -707,14 +707,14 @@ private static List GetSimilarMemberNames(Type type, string name) // 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). - string candidate = ToSnakeCaseMemberName(member); + var candidate = ToSnakeCaseMemberName(member); if (!seen.Add(candidate)) { continue; } - int distance = LevenshteinDistance(name, candidate); - bool related = distance <= threshold + var distance = LevenshteinDistance(name, candidate); + var related = distance <= threshold || candidate.IndexOf(name, StringComparison.OrdinalIgnoreCase) >= 0 || name.IndexOf(candidate, StringComparison.OrdinalIgnoreCase) >= 0; if (related) @@ -747,20 +747,21 @@ private static int LevenshteinDistance(string a, string b) { a = a.ToLowerInvariant(); b = b.ToLowerInvariant(); - int n = a.Length, m = b.Length; + var n = a.Length; + var m = b.Length; if (n == 0) return m; if (m == 0) return n; var prev = new int[m + 1]; var curr = new int[m + 1]; - for (int j = 0; j <= m; j++) prev[j] = j; + for (var j = 0; j <= m; j++) prev[j] = j; - for (int i = 1; i <= n; i++) + for (var i = 1; i <= n; i++) { curr[0] = i; - for (int j = 1; j <= m; j++) + for (var j = 1; j <= m; j++) { - int cost = a[i - 1] == b[j - 1] ? 0 : 1; + var cost = a[i - 1] == b[j - 1] ? 0 : 1; curr[j] = Math.Min(Math.Min(curr[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost); } (prev, curr) = (curr, prev); From c0acedd6786150897f20bcf79886b2469f4bee39 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 30 Jun 2026 17:06:46 -0400 Subject: [PATCH 4/5] Use a miss-only __getattr__ hook for AttributeError suggestions Move the suggestion logic for regular reflected types off the hot attribute-access path. Previously ClassObject overrode tp_getattro (__getattribute__), so every successful attribute access paid a managed round-trip (~17 ns/access measured). Instead, install a shared __getattr__ on each reflected type, which CPython only invokes on a miss via slot_tp_getattr_hook; hits go straight through the native generic getattr with no managed transition. pythonnet's metatype does not run CPython's slot-fixup when attributes are set on a type, so AttributeErrorHint wires tp_getattro to the hook manually (the hook address is read from a probe class). Only types still using the native generic getattr are redirected, so dynamic objects, modules and interfaces (which have their own tp_getattro) are untouched; DynamicClassObject keeps enriching on its own miss path. Benchmark (System.Version, best-of-N, ns/access): hit path: ~109 ns (baseline ~105; tp_getattro was ~122) miss path: ~11 us (rare; reflection + Levenshtein, as before) The hot-path overhead is effectively eliminated; the extra miss-path cost only applies when an attribute is actually absent. Co-Authored-By: Claude Opus 4.8 --- src/runtime/AttributeErrorHint.cs | 109 ++++++++++++++++++++++++++++++ src/runtime/PythonEngine.cs | 7 ++ src/runtime/TypeManager.cs | 4 ++ src/runtime/Types/ClassBase.cs | 73 +++++++++++++++++--- src/runtime/Types/ClassObject.cs | 15 ---- 5 files changed, 182 insertions(+), 26 deletions(-) create mode 100644 src/runtime/AttributeErrorHint.cs diff --git a/src/runtime/AttributeErrorHint.cs b/src/runtime/AttributeErrorHint.cs new file mode 100644 index 000000000..f7feec985 --- /dev/null +++ b/src/runtime/AttributeErrorHint.cs @@ -0,0 +1,109 @@ +using System; + +using Python.Runtime.Native; + +namespace Python.Runtime +{ + /// + /// Installs a miss-only __getattr__ hook on reflected .NET types so that an + /// AttributeError raised for a missing attribute is enriched with suggestions + /// of similarly-named members — without adding any cost to the (common) successful + /// attribute-access path. + /// + /// + /// CPython only invokes __getattr__ after the normal attribute lookup fails, + /// via the native slot_tp_getattr_hook: on a hit it calls the generic getattr + /// directly (no managed transition); only on a miss does it call our __getattr__. + /// pythonnet's metatype does not run CPython's slot-fixup machinery when an attribute + /// is set on a type, so simply adding __getattr__ to the type dict would not + /// rewire the slot — we therefore wire tp_getattro to the hook manually. + /// + internal static class AttributeErrorHint + { + // The shared __getattr__ function object installed on every eligible type. + private static PyObject? _getAttr; + // The managed message builder exposed to Python, kept alive for _getAttr's globals. + private static PyObject? _messageBuilder; + // Address of CPython's slot_tp_getattr_hook (extracted from a probe type). + private static IntPtr _hookSlot; + // Address of PyObject_GenericGetAttr, used to detect types we may safely redirect. + private static IntPtr _genericGetAttr; + + private static bool IsReady => _getAttr is not null && _hookSlot != IntPtr.Zero; + + internal static void Initialize() + { + try + { + _genericGetAttr = Util.ReadIntPtr(Runtime.PyBaseObjectType, TypeOffset.tp_getattro); + + Func builder = ClassBase.BuildMissingAttributeMessage; + _messageBuilder = builder.ToPython(); + + using var globals = new PyDict(); + Runtime.PyDict_SetItemString(globals.Reference, "__builtins__", Runtime.PyEval_GetBuiltins()); + globals["__clr_attr_msg__"] = _messageBuilder; + + // Define the shared hook, plus a probe class whose tp_getattro is + // slot_tp_getattr_hook so we can read that function pointer. + PythonEngine.Exec( + "def __clr_getattr__(self, name):\n" + + " raise AttributeError(__clr_attr_msg__(self, name))\n" + + "class __clr_getattr_probe__:\n" + + " def __getattr__(self, name):\n" + + " raise AttributeError(name)\n", + globals); + + _getAttr = globals["__clr_getattr__"]; + using var probe = globals["__clr_getattr_probe__"]; + _hookSlot = Util.ReadIntPtr(probe.Reference, TypeOffset.tp_getattro); + } + catch (Exception e) + { + // Degrade gracefully: without the hook, AttributeError messages are simply + // not enriched. Never let this break interpreter initialization. + DebugUtil.Print($"AttributeErrorHint.Initialize failed: {e}"); + Shutdown(); + } + } + + /// + /// Wires the miss-only hook onto if it still uses the + /// native generic getattr. Types with a custom tp_getattro (dynamic + /// objects, modules, interfaces, ...) handle misses themselves and are left + /// untouched; derived types that inherit an already-hooked base are likewise + /// skipped, since they inherit the behavior through the MRO. + /// + internal static void Install(BorrowedReference type) + { + if (!IsReady) + { + return; + } + + if (Util.ReadIntPtr(type, TypeOffset.tp_getattro) != _genericGetAttr) + { + return; + } + + if (Runtime.PyObject_SetAttrString(type, "__getattr__", _getAttr!.Reference) != 0) + { + Exceptions.Clear(); + return; + } + + Util.WriteIntPtr(type, TypeOffset.tp_getattro, _hookSlot); + Runtime.PyType_Modified(type); + } + + internal static void Shutdown() + { + _getAttr?.Dispose(); + _getAttr = null; + _messageBuilder?.Dispose(); + _messageBuilder = null; + _hookSlot = IntPtr.Zero; + _genericGetAttr = IntPtr.Zero; + } + } +} diff --git a/src/runtime/PythonEngine.cs b/src/runtime/PythonEngine.cs index eb0c98ce9..677a44978 100644 --- a/src/runtime/PythonEngine.cs +++ b/src/runtime/PythonEngine.cs @@ -263,6 +263,10 @@ public static void Initialize(IEnumerable args, bool setSysArgv = true, } ImportHook.UpdateCLRModuleDict(); + + // Set up the miss-only __getattr__ hook used to enrich AttributeError + // messages on reflected .NET types with member-name suggestions. + AttributeErrorHint.Initialize(); } static BorrowedReference DefineModule(string name) @@ -369,6 +373,9 @@ public static void Shutdown() AppDomain.CurrentDomain.ProcessExit -= OnProcessExit; ExecuteShutdownHandlers(); + + AttributeErrorHint.Shutdown(); + // Remember to shut down the runtime. Runtime.Shutdown(); diff --git a/src/runtime/TypeManager.cs b/src/runtime/TypeManager.cs index 3b75738b2..cbaa730ca 100644 --- a/src/runtime/TypeManager.cs +++ b/src/runtime/TypeManager.cs @@ -303,6 +303,10 @@ internal static void InitializeClass(PyType type, ClassBase impl, Type clrType) Runtime.PyType_Modified(type.Reference); + // Enrich AttributeError messages for missing attributes with member-name + // suggestions, via a miss-only __getattr__ hook (no hot-path cost). + AttributeErrorHint.Install(type.Reference); + //DebugUtil.DumpType(type); } diff --git a/src/runtime/Types/ClassBase.cs b/src/runtime/Types/ClassBase.cs index 617baae49..7e831d17f 100644 --- a/src/runtime/Types/ClassBase.cs +++ b/src/runtime/Types/ClassBase.cs @@ -628,20 +628,13 @@ internal static void AppendAttributeErrorSuggestions(BorrowedReference ob, Borro } var name = Runtime.GetManagedString(key); - // Skip empty and dunder names: the latter are probed internally by CPython - // (e.g. __iter__, __len__) and are never user-facing typos worth helping with. - if (string.IsNullOrEmpty(name) || name.StartsWith("__", StringComparison.Ordinal)) + if (string.IsNullOrEmpty(name)) { return; } - if (GetManagedObject(ob) is not CLRObject clrObj || clrObj.inst is null) - { - return; - } - - var suggestions = GetSimilarMemberNames(clrObj.inst.GetType(), name); - if (suggestions.Count == 0) + var hint = GetSuggestionHint(ob, name); + if (hint.Length == 0) { return; } @@ -651,7 +644,6 @@ internal static void AppendAttributeErrorSuggestions(BorrowedReference ob, Borro try { var baseMessage = GetErrorMessage(errValue.BorrowNullable(), name); - var hint = " Did you mean: " + string.Join(", ", suggestions.Select(s => $"'{s}'")) + "?"; Exceptions.SetError(Exceptions.AttributeError, baseMessage + hint); } finally @@ -662,6 +654,65 @@ internal static void AppendAttributeErrorSuggestions(BorrowedReference ob, Borro } } + /// + /// Builds the full message for an AttributeError raised for a missing + /// attribute on a .NET object, including any "Did you mean ...?" hint. Used by + /// the miss-only __getattr__ hook installed on reflected types (see + /// ), where the original error has already been + /// cleared, so the base message is reconstructed here. + /// + internal static string BuildMissingAttributeMessage(PyObject self, string name) + { + var typeName = "object"; + try + { + using var pyType = self.GetPythonType(); + typeName = pyType.Name; + } + catch + { + // fall back to the generic type name + } + + var message = $"'{typeName}' object has no attribute '{name}'"; + try + { + return message + GetSuggestionHint(self.Reference, name); + } + catch + { + // never let suggestion building turn into a different exception + return message; + } + } + + /// + /// Returns " Did you mean: 'x', 'y'?" listing similarly-named members of the + /// managed object, or an empty string when there is nothing to suggest. Dunder + /// names are skipped: they are probed internally by CPython (e.g. __iter__, + /// __len__) and are never user-facing typos worth helping with. + /// + private static string GetSuggestionHint(BorrowedReference ob, string name) + { + if (string.IsNullOrEmpty(name) || name.StartsWith("__", StringComparison.Ordinal)) + { + return string.Empty; + } + + if (GetManagedObject(ob) is not CLRObject clrObj || clrObj.inst is null) + { + 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}'")) + "?"; + } + private static string GetErrorMessage(BorrowedReference value, string fallbackName) { if (value != null) diff --git a/src/runtime/Types/ClassObject.cs b/src/runtime/Types/ClassObject.cs index 48a975898..b57378a32 100644 --- a/src/runtime/Types/ClassObject.cs +++ b/src/runtime/Types/ClassObject.cs @@ -167,21 +167,6 @@ public override void InitializeSlots(BorrowedReference pyType, SlotsHolder slots protected virtual NewReference NewObjectToPython(object obj, BorrowedReference tp) => CLRObject.GetReference(obj, tp); - /// - /// Type __getattro__ implementation. Delegates to the generic CLR attribute - /// lookup, but enriches the AttributeError raised for a missing attribute with - /// suggestions of similarly-named members of the managed type. - /// - public static NewReference tp_getattro(BorrowedReference ob, BorrowedReference key) - { - var result = Runtime.PyObject_GenericGetAttr(ob, key); - if (result.IsNull()) - { - AppendAttributeErrorSuggestions(ob, key); - } - return result; - } - private static NewReference NewEnum(Type type, BorrowedReference args, BorrowedReference tp) { nint argCount = Runtime.PyTuple_Size(args); From cd2ef5ea5f9338cf4be01e97d7fe4d9ede460d5a Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Wed, 1 Jul 2026 10:24:32 -0400 Subject: [PATCH 5/5] Bump version to 2.0.55 --- 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 17af4024c..1260a79fd 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 06f73394d..bca5261f0 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.54")] -[assembly: AssemblyFileVersion("2.0.54")] +[assembly: AssemblyVersion("2.0.55")] +[assembly: AssemblyFileVersion("2.0.55")] diff --git a/src/runtime/Python.Runtime.csproj b/src/runtime/Python.Runtime.csproj index 953fdcba0..c9dbda45a 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.54 + 2.0.55 false LICENSE https://github.com/pythonnet/pythonnet