https://github.com/python/cpython/commit/b6b72e766338490305f756e25b0e4725e1b31cd7
commit: b6b72e766338490305f756e25b0e4725e1b31cd7
branch: main
author: Bartosz Sławecki <[email protected]>
committer: pablogsal <[email protected]>
date: 2026-02-12T14:12:49Z
summary:

gh-144285: Improve `AttributeError` attribute suggestions (#144299)

files:
A Misc/NEWS.d/next/Library/2026-02-07-16-31-42.gh-issue-144285.iyH9iL.rst
M Lib/idlelib/idle_test/test_run.py
M Lib/test/test_traceback.py
M Lib/traceback.py

diff --git a/Lib/idlelib/idle_test/test_run.py 
b/Lib/idlelib/idle_test/test_run.py
index 83ecbffa2a197e..9a9d3b7b4e219c 100644
--- a/Lib/idlelib/idle_test/test_run.py
+++ b/Lib/idlelib/idle_test/test_run.py
@@ -44,7 +44,7 @@ def __eq__(self, other):
                                "Or did you forget to import 'abc'?\n"),
             ('int.reel', AttributeError,
                  "type object 'int' has no attribute 'reel'. "
-                 "Did you mean: 'real'?\n"),
+                 "Did you mean '.real' instead of '.reel'?\n"),
             )
 
     @force_not_colorized
diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py
index eaca62b12d3eb1..99ac7fd83d91cb 100644
--- a/Lib/test/test_traceback.py
+++ b/Lib/test/test_traceback.py
@@ -4176,25 +4176,25 @@ class CaseChangeOverSubstitution:
             BLuch = None
 
         for cls, suggestion in [
-            (Addition, "'bluchin'?"),
-            (Substitution, "'blech'?"),
-            (Elimination, "'blch'?"),
-            (Addition, "'bluchin'?"),
-            (SubstitutionOverElimination, "'blach'?"),
-            (SubstitutionOverAddition, "'blach'?"),
-            (EliminationOverAddition, "'bluc'?"),
-            (CaseChangeOverSubstitution, "'BLuch'?"),
+            (Addition, "'.bluchin'"),
+            (Substitution, "'.blech'"),
+            (Elimination, "'.blch'"),
+            (Addition, "'.bluchin'"),
+            (SubstitutionOverElimination, "'.blach'"),
+            (SubstitutionOverAddition, "'.blach'"),
+            (EliminationOverAddition, "'.bluc'"),
+            (CaseChangeOverSubstitution, "'.BLuch'"),
         ]:
             actual = self.get_suggestion(cls(), 'bluch')
-            self.assertIn(suggestion, actual)
+            self.assertIn('Did you mean ' + suggestion, actual)
 
     def test_suggestions_underscored(self):
         class A:
             bluch = None
 
-        self.assertIn("'bluch'", self.get_suggestion(A(), 'blach'))
-        self.assertIn("'bluch'", self.get_suggestion(A(), '_luch'))
-        self.assertIn("'bluch'", self.get_suggestion(A(), '_bluch'))
+        self.assertIn("'.bluch'", self.get_suggestion(A(), 'blach'))
+        self.assertIn("'.bluch'", self.get_suggestion(A(), '_luch'))
+        self.assertIn("'.bluch'", self.get_suggestion(A(), '_bluch'))
 
         attr_function = self.attr_function
         class B:
@@ -4202,13 +4202,13 @@ class B:
             def method(self, name):
                 attr_function(self, name)
 
-        self.assertIn("'_bluch'", self.get_suggestion(B(), '_blach'))
-        self.assertIn("'_bluch'", self.get_suggestion(B(), '_luch'))
-        self.assertNotIn("'_bluch'", self.get_suggestion(B(), 'bluch'))
+        self.assertIn("'._bluch'", self.get_suggestion(B(), '_blach'))
+        self.assertIn("'._bluch'", self.get_suggestion(B(), '_luch'))
+        self.assertNotIn("'._bluch'", self.get_suggestion(B(), 'bluch'))
 
-        self.assertIn("'_bluch'", self.get_suggestion(partial(B().method, 
'_blach')))
-        self.assertIn("'_bluch'", self.get_suggestion(partial(B().method, 
'_luch')))
-        self.assertIn("'_bluch'", self.get_suggestion(partial(B().method, 
'bluch')))
+        self.assertIn("'._bluch'", self.get_suggestion(partial(B().method, 
'_blach')))
+        self.assertIn("'._bluch'", self.get_suggestion(partial(B().method, 
'_luch')))
+        self.assertIn("'._bluch'", self.get_suggestion(partial(B().method, 
'bluch')))
 
 
     def test_do_not_trigger_for_long_attributes(self):
@@ -4256,7 +4256,7 @@ class A:
             fiⁿₐˡᵢᶻₐᵗᵢᵒₙ = None
 
         suggestion = self.get_suggestion(A(), 'fiⁿₐˡᵢᶻₐᵗᵢᵒₙ')
-        self.assertIn("'finalization'", suggestion)
+        self.assertIn("'.finalization'", suggestion)
         self.assertNotIn("analization", suggestion)
 
         class B:
@@ -4264,8 +4264,10 @@ class B:
             attr_µ = None  # attr_\xb5
 
         suggestion = self.get_suggestion(B(), 'attr_\xb5')
-        self.assertIn("'attr_\u03bc'", suggestion)
-        self.assertIn(r"'attr_\u03bc'", suggestion)
+        self.assertIn(
+            "'.attr_\u03bc' ('attr_\\u03bc') "
+            "instead of '.attr_\xb5' ('attr_\\xb5')",
+            suggestion)
         self.assertNotIn("attr_a", suggestion)
 
 
@@ -4371,11 +4373,11 @@ def __init__(self):
 
         # Should suggest 'inner.value'
         actual = self.get_suggestion(Outer(), 'value')
-        self.assertIn("Did you mean: 'inner.value'", actual)
+        self.assertIn("Did you mean '.inner.value' instead of '.value'", 
actual)
 
         # Should suggest 'inner.data'
         actual = self.get_suggestion(Outer(), 'data')
-        self.assertIn("Did you mean: 'inner.data'", actual)
+        self.assertIn("Did you mean '.inner.data' instead of '.data'", actual)
 
     def test_getattr_nested_prioritizes_direct_matches(self):
         # Test that direct attribute matches are prioritized over nested ones
@@ -4390,7 +4392,7 @@ def __init__(self):
 
         # Should suggest 'fooo' (direct) not 'inner.foo' (nested)
         actual = self.get_suggestion(Outer(), 'foo')
-        self.assertIn("Did you mean: 'fooo'", actual)
+        self.assertIn("Did you mean '.fooo'", actual)
         self.assertNotIn("inner.foo", actual)
 
     def test_getattr_nested_with_property(self):
@@ -4487,7 +4489,7 @@ def __init__(self):
 
         # Should suggest only the first match (alphabetically)
         actual = self.get_suggestion(Outer(), 'value')
-        self.assertIn("'a_inner.value'", actual)
+        self.assertIn("'.a_inner.value'", actual)
         # Verify it's a single suggestion, not multiple
         self.assertEqual(actual.count("Did you mean"), 1)
 
@@ -4510,10 +4512,10 @@ def __init__(self):
                 self.exploder = ExplodingProperty()  # Accessing attributes 
will raise
                 self.safe_inner = SafeInner()
 
-        # Should still suggest 'safe_inner.target' without crashing
+        # Should still suggest '.safe_inner.target' without crashing
         # even though accessing exploder.target would raise an exception
         actual = self.get_suggestion(Outer(), 'target')
-        self.assertIn("'safe_inner.target'", actual)
+        self.assertIn("'.safe_inner.target'", actual)
 
     def test_getattr_nested_handles_hasattr_exceptions(self):
         # Test that exceptions in hasattr don't crash the system
@@ -4534,7 +4536,7 @@ def __init__(self):
 
         # Should still find 'normal.target' even though weird.target check 
fails
         actual = self.get_suggestion(Outer(), 'target')
-        self.assertIn("'normal.target'", actual)
+        self.assertIn("'.normal.target'", actual)
 
     def make_module(self, code):
         tmpdir = Path(tempfile.mkdtemp())
diff --git a/Lib/traceback.py b/Lib/traceback.py
index b121733c27fd8c..42453b4867ce99 100644
--- a/Lib/traceback.py
+++ b/Lib/traceback.py
@@ -1128,7 +1128,16 @@ def __init__(self, exc_type, exc_value, exc_traceback, 
*, limit=None,
                 self._str += (". Site initialization is disabled, did you 
forget to "
                     + "add the site-packages directory to sys.path "
                     + "or to enable your virtual environment?")
-        elif exc_type and issubclass(exc_type, (NameError, AttributeError)) 
and \
+        elif exc_type and issubclass(exc_type, AttributeError) and \
+                getattr(exc_value, "name", None) is not None:
+            wrong_name = getattr(exc_value, "name", None)
+            suggestion = _compute_suggestion_error(exc_value, exc_traceback, 
wrong_name)
+            if suggestion:
+                if suggestion.isascii():
+                    self._str += f". Did you mean '.{suggestion}' instead of 
'.{wrong_name}'?"
+                else:
+                    self._str += f". Did you mean '.{suggestion}' 
({suggestion!a}) instead of '.{wrong_name}' ({wrong_name!a})?"
+        elif exc_type and issubclass(exc_type, NameError) and \
                 getattr(exc_value, "name", None) is not None:
             wrong_name = getattr(exc_value, "name", None)
             suggestion = _compute_suggestion_error(exc_value, exc_traceback, 
wrong_name)
@@ -1137,13 +1146,11 @@ def __init__(self, exc_type, exc_value, exc_traceback, 
*, limit=None,
                     self._str += f". Did you mean: '{suggestion}'?"
                 else:
                     self._str += f". Did you mean: '{suggestion}' 
({suggestion!a})?"
-            if issubclass(exc_type, NameError):
-                wrong_name = getattr(exc_value, "name", None)
-                if wrong_name is not None and wrong_name in 
sys.stdlib_module_names:
-                    if suggestion:
-                        self._str += f" Or did you forget to import 
'{wrong_name}'?"
-                    else:
-                        self._str += f". Did you forget to import 
'{wrong_name}'?"
+            if wrong_name is not None and wrong_name in 
sys.stdlib_module_names:
+                if suggestion:
+                    self._str += f" Or did you forget to import 
'{wrong_name}'?"
+                else:
+                    self._str += f". Did you forget to import '{wrong_name}'?"
         if lookup_lines:
             self._load_lines()
         self.__suppress_context__ = \
diff --git 
a/Misc/NEWS.d/next/Library/2026-02-07-16-31-42.gh-issue-144285.iyH9iL.rst 
b/Misc/NEWS.d/next/Library/2026-02-07-16-31-42.gh-issue-144285.iyH9iL.rst
new file mode 100644
index 00000000000000..e1119a85e9c1f3
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-02-07-16-31-42.gh-issue-144285.iyH9iL.rst
@@ -0,0 +1,3 @@
+Attribute suggestions in :exc:`AttributeError` tracebacks are now formatted 
differently
+to make them easier to understand, for example: ``Did you mean '.datetime.now' 
instead of '.now'``.
+Contributed by Bartosz Sławecki.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to