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