diff --git a/src/embed_tests/TestPropertyAccess.cs b/src/embed_tests/TestPropertyAccess.cs index 8dba383d6..1c9d0e7fd 100644 --- a/src/embed_tests/TestPropertyAccess.cs +++ b/src/embed_tests/TestPropertyAccess.cs @@ -1129,6 +1129,44 @@ 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: + # '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 + + 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); + + // Suggestions are emitted in snake_case, matching the fork's PEP8-style API. + var message = result.ToString(); + Assert.That(message, Does.Contain("non_dynamic_propertyy")); + Assert.That(message, Does.Contain("Did you mean")); + Assert.That(message, Does.Contain("non_dynamic_property")); + } + } + public class CSharpTestClass { public string CSharpProperty { get; set; } 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/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/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 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 590c870b5..7e831d17f 100644 --- a/src/runtime/Types/ClassBase.cs +++ b/src/runtime/Types/ClassBase.cs @@ -611,5 +611,213 @@ 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; + } + + var name = Runtime.GetManagedString(key); + if (string.IsNullOrEmpty(name)) + { + return; + } + + var hint = GetSuggestionHint(ob, name); + if (hint.Length == 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 + { + var baseMessage = GetErrorMessage(errValue.BorrowNullable(), name); + Exceptions.SetError(Exceptions.AttributeError, baseMessage + hint); + } + finally + { + errType.Dispose(); + errValue.Dispose(); + errTraceback.Dispose(); + } + } + + /// + /// 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) + { + using var str = Runtime.PyObject_Str(value); + if (!str.IsNull()) + { + var 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; + var 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; + } + + 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)) + { + continue; + } + + var distance = LevenshteinDistance(name, candidate); + var 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 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(); + b = b.ToLowerInvariant(); + 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 (var j = 0; j <= m; j++) prev[j] = j; + + for (var i = 1; i <= n; i++) + { + curr[0] = i; + for (var j = 1; j <= m; j++) + { + 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); + } + return prev[m]; + } } } 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..4f0effecd 100644 --- a/tests/test_class.py +++ b/tests/test_class.py @@ -66,6 +66,44 @@ 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. + + 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' (the snake_case alias of the real + # 'Length' 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