]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-144285: Improve `AttributeError` attribute suggestions (#144299)
authorBartosz Sławecki <bartosz@ilikepython.com>
Thu, 12 Feb 2026 14:12:49 +0000 (15:12 +0100)
committerGitHub <noreply@github.com>
Thu, 12 Feb 2026 14:12:49 +0000 (14:12 +0000)
Lib/idlelib/idle_test/test_run.py
Lib/test/test_traceback.py
Lib/traceback.py
Misc/NEWS.d/next/Library/2026-02-07-16-31-42.gh-issue-144285.iyH9iL.rst [new file with mode: 0644]

index 83ecbffa2a197e7460ba98402f197a689bd43a4d..9a9d3b7b4e219c2e2d0f32cd0a22112598afe9c3 100644 (file)
@@ -44,7 +44,7 @@ class ExceptionTest(unittest.TestCase):
                                "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
index eaca62b12d3eb1261dd089f8ea7729bbedddc38c..99ac7fd83d91cb8c8dd58aa5a6f7574eaa561144 100644 (file)
@@ -4176,25 +4176,25 @@ class BaseSuggestionTests(SuggestionFormattingTestMixin):
             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 BaseSuggestionTests(SuggestionFormattingTestMixin):
             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 BaseSuggestionTests(SuggestionFormattingTestMixin):
             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 BaseSuggestionTests(SuggestionFormattingTestMixin):
             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 @@ class SuggestionFormattingTestBase(SuggestionFormattingTestMixin):
 
         # 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 @@ class SuggestionFormattingTestBase(SuggestionFormattingTestMixin):
 
         # 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 @@ class SuggestionFormattingTestBase(SuggestionFormattingTestMixin):
 
         # 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 @@ class SuggestionFormattingTestBase(SuggestionFormattingTestMixin):
                 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 @@ class SuggestionFormattingTestBase(SuggestionFormattingTestMixin):
 
         # 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())
index b121733c27fd8c1b233d35d7da5f7460fdd20811..42453b4867ce994fd839bf876523af8d5be83bf4 100644 (file)
@@ -1128,7 +1128,16 @@ class TracebackException:
                 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 @@ class TracebackException:
                     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 (file)
index 0000000..e1119a8
--- /dev/null
@@ -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.