diff --git a/src/embed_tests/TestPropertyAccess.cs b/src/embed_tests/TestPropertyAccess.cs index 1c9d0e7fd..8dba383d6 100644 --- a/src/embed_tests/TestPropertyAccess.cs +++ b/src/embed_tests/TestPropertyAccess.cs @@ -1129,44 +1129,6 @@ 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 1260a79fd..17af4024c 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 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/Properties/AssemblyInfo.cs b/src/runtime/Properties/AssemblyInfo.cs index bca5261f0..06f73394d 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.55")] -[assembly: AssemblyFileVersion("2.0.55")] +[assembly: AssemblyVersion("2.0.54")] +[assembly: AssemblyFileVersion("2.0.54")] diff --git a/src/runtime/Python.Runtime.csproj b/src/runtime/Python.Runtime.csproj index c9dbda45a..953fdcba0 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.54 false LICENSE https://github.com/pythonnet/pythonnet 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..590c870b5 100644 --- a/src/runtime/Types/ClassBase.cs +++ b/src/runtime/Types/ClassBase.cs @@ -611,213 +611,5 @@ 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 621a6f423..cb6fd5650 100644 --- a/src/runtime/Types/DynamicClassObject.cs +++ b/src/runtime/Types/DynamicClassObject.cs @@ -80,10 +80,7 @@ public static NewReference tp_getattro(BorrowedReference ob, BorrowedReference k } catch (RuntimeBinder.RuntimeBinderException) { - // 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); + // Do nothing, AttributeError was already raised in Python side and it was not cleared. } // 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 4f0effecd..ec275d752 100644 --- a/tests/test_class.py +++ b/tests/test_class.py @@ -66,44 +66,6 @@ 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