From 53e981ae06a28345d3432a170ca984edb27db22a Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Wed, 1 Jul 2026 18:06:01 -0400 Subject: [PATCH 1/2] Use tp_getattro instead of the miss-only __getattr__ hook Reimplement the AttributeError member-name suggestions on top of a ClassObject.tp_getattro override, replacing the miss-only __getattr__ hook (AttributeErrorHint) that was merged in #124. The hook installed a shared __getattr__ on every reflected type and manually rewired tp_getattro to CPython's slot_tp_getattr_hook. That surgery is significantly more invasive for no measurable real-world benefit: the per-access cost it avoids (~17 ns) is lost in the noise on realistic Lean workloads. This version keeps the enrichment entirely in a tp_getattro override that delegates to PyObject_GenericGetAttr and only does work on a miss, and drops all the slot manipulation. Behaviour and messages are unchanged (snake_case "Did you mean ...?" suggestions); the existing Python and embedding tests are untouched and still pass. 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, 26 insertions(+), 182 deletions(-) delete mode 100644 src/runtime/AttributeErrorHint.cs diff --git a/src/runtime/AttributeErrorHint.cs b/src/runtime/AttributeErrorHint.cs deleted file mode 100644 index f7feec985..000000000 --- a/src/runtime/AttributeErrorHint.cs +++ /dev/null @@ -1,109 +0,0 @@ -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 677a44978..eb0c98ce9 100644 --- a/src/runtime/PythonEngine.cs +++ b/src/runtime/PythonEngine.cs @@ -263,10 +263,6 @@ 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) @@ -373,9 +369,6 @@ 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 cbaa730ca..3b75738b2 100644 --- a/src/runtime/TypeManager.cs +++ b/src/runtime/TypeManager.cs @@ -303,10 +303,6 @@ 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 7e831d17f..617baae49 100644 --- a/src/runtime/Types/ClassBase.cs +++ b/src/runtime/Types/ClassBase.cs @@ -628,13 +628,20 @@ internal static void AppendAttributeErrorSuggestions(BorrowedReference ob, Borro } var name = Runtime.GetManagedString(key); - if (string.IsNullOrEmpty(name)) + // 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; } - var hint = GetSuggestionHint(ob, name); - if (hint.Length == 0) + if (GetManagedObject(ob) is not CLRObject clrObj || clrObj.inst is null) + { + return; + } + + var suggestions = GetSimilarMemberNames(clrObj.inst.GetType(), name); + if (suggestions.Count == 0) { return; } @@ -644,6 +651,7 @@ 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 @@ -654,65 +662,6 @@ 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 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); From 0bfad8c9351d3a538f20a912028c58e4e02f663f Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Wed, 1 Jul 2026 18:09:26 -0400 Subject: [PATCH 2/2] Bump version to 2.0.56 Co-Authored-By: Claude Opus 4.8 --- src/runtime/Python.Runtime.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runtime/Python.Runtime.csproj b/src/runtime/Python.Runtime.csproj index c9dbda45a..f06f19706 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.55 + 2.0.56 false LICENSE https://github.com/pythonnet/pythonnet