]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-130425: Add "Did you mean [...]" suggestions for `del obj.attr` (GH-136588)
authorPranjal Prajapati <145594122+Pranjal095@users.noreply.github.com>
Fri, 22 Aug 2025 10:21:16 +0000 (15:51 +0530)
committerGitHub <noreply@github.com>
Fri, 22 Aug 2025 10:21:16 +0000 (12:21 +0200)
Co-authored-by: sobolevn <mail@sobolevn.me>
Co-authored-by: Ned Batchelder <ned@nedbatchelder.com>
Co-authored-by: Tomas R. <tomas.roun8@gmail.com>
Co-authored-by: Petr Viktorin <encukou@gmail.com>
Doc/whatsnew/3.15.rst
Lib/test/test_traceback.py
Misc/NEWS.d/next/Core_and_Builtins/2025-02-22-01-23-23.gh-issue-130425.x5SNQ8.rst [new file with mode: 0644]
Objects/dictobject.c

index 7748c172e63b6dd4f55f2bcc08cc663537750b5e..54a7d0f3c57dad4f8c304bcfb34099c6be6f5735 100644 (file)
@@ -239,6 +239,25 @@ Other language changes
 * Several error messages incorrectly using the term "argument" have been corrected.
   (Contributed by Stan Ulbrych in :gh:`133382`.)
 
+* The interpreter now tries to provide a suggestion when
+  :func:`delattr` fails due to a missing attribute.
+  When an attribute name that closely resembles an existing attribute is used,
+  the interpreter will suggest the correct attribute name in the error message.
+  For example:
+
+  .. doctest::
+
+     >>> class A:
+     ...     pass
+     >>> a = A()
+     >>> a.abcde = 1
+     >>> del a.abcdf  # doctest: +ELLIPSIS
+     Traceback (most recent call last):
+     ...
+     AttributeError: 'A' object has no attribute 'abcdf'. Did you mean: 'abcde'?
+
+  (Contributed by Nikita Sobolev and Pranjal Prajapati in :gh:`136588`.)
+
 * Unraisable exceptions are now highlighted with color by default. This can be
   controlled by :ref:`environment variables <using-on-controlling-color>`.
   (Contributed by Peter Bierma in :gh:`134170`.)
index 046385478b5f199e638a20fd00b999806ee5bc11..bd3ecfd9a3863dafa279d1583acd5c66640d6331 100644 (file)
@@ -4064,11 +4064,13 @@ class TestTracebackException_ExceptionGroups(unittest.TestCase):
 global_for_suggestions = None
 
 
-class SuggestionFormattingTestBase:
+class SuggestionFormattingTestMixin:
+    attr_function = getattr
+
     def get_suggestion(self, obj, attr_name=None):
         if attr_name is not None:
             def callable():
-                getattr(obj, attr_name)
+                self.attr_function(obj, attr_name)
         else:
             callable = obj
 
@@ -4077,7 +4079,9 @@ class SuggestionFormattingTestBase:
         )
         return result_lines[0]
 
-    def test_getattr_suggestions(self):
+
+class BaseSuggestionTests(SuggestionFormattingTestMixin):
+    def test_suggestions(self):
         class Substitution:
             noise = more_noise = a = bc = None
             blech = None
@@ -4120,7 +4124,7 @@ class SuggestionFormattingTestBase:
             actual = self.get_suggestion(cls(), 'bluch')
             self.assertIn(suggestion, actual)
 
-    def test_getattr_suggestions_underscored(self):
+    def test_suggestions_underscored(self):
         class A:
             bluch = None
 
@@ -4128,10 +4132,11 @@ class SuggestionFormattingTestBase:
         self.assertIn("'bluch'", self.get_suggestion(A(), '_luch'))
         self.assertIn("'bluch'", self.get_suggestion(A(), '_bluch'))
 
+        attr_function = self.attr_function
         class B:
             _bluch = None
             def method(self, name):
-                getattr(self, name)
+                attr_function(self, name)
 
         self.assertIn("'_bluch'", self.get_suggestion(B(), '_blach'))
         self.assertIn("'_bluch'", self.get_suggestion(B(), '_luch'))
@@ -4141,20 +4146,21 @@ class SuggestionFormattingTestBase:
         self.assertIn("'_bluch'", self.get_suggestion(partial(B().method, '_luch')))
         self.assertIn("'_bluch'", self.get_suggestion(partial(B().method, 'bluch')))
 
-    def test_getattr_suggestions_do_not_trigger_for_long_attributes(self):
+
+    def test_do_not_trigger_for_long_attributes(self):
         class A:
             blech = None
 
         actual = self.get_suggestion(A(), 'somethingverywrong')
         self.assertNotIn("blech", actual)
 
-    def test_getattr_error_bad_suggestions_do_not_trigger_for_small_names(self):
+    def test_do_not_trigger_for_small_names(self):
         class MyClass:
             vvv = mom = w = id = pytho = None
 
         for name in ("b", "v", "m", "py"):
             with self.subTest(name=name):
-                actual = self.get_suggestion(MyClass, name)
+                actual = self.get_suggestion(MyClass(), name)
                 self.assertNotIn("Did you mean", actual)
                 self.assertNotIn("'vvv", actual)
                 self.assertNotIn("'mom'", actual)
@@ -4162,7 +4168,7 @@ class SuggestionFormattingTestBase:
                 self.assertNotIn("'w'", actual)
                 self.assertNotIn("'pytho'", actual)
 
-    def test_getattr_suggestions_do_not_trigger_for_big_dicts(self):
+    def test_do_not_trigger_for_big_dicts(self):
         class A:
             blech = None
         # A class with a very big __dict__ will not be considered
@@ -4173,7 +4179,16 @@ class SuggestionFormattingTestBase:
         actual = self.get_suggestion(A(), 'bluch')
         self.assertNotIn("blech", actual)
 
-    def test_getattr_suggestions_no_args(self):
+    def test_suggestions_for_same_name(self):
+        class A:
+            def __dir__(self):
+                return ['blech']
+        actual = self.get_suggestion(A(), 'blech')
+        self.assertNotIn("Did you mean", actual)
+
+
+class GetattrSuggestionTests(BaseSuggestionTests):
+    def test_suggestions_no_args(self):
         class A:
             blech = None
             def __getattr__(self, attr):
@@ -4190,7 +4205,7 @@ class SuggestionFormattingTestBase:
         actual = self.get_suggestion(A(), 'bluch')
         self.assertIn("blech", actual)
 
-    def test_getattr_suggestions_invalid_args(self):
+    def test_suggestions_invalid_args(self):
         class NonStringifyClass:
             __str__ = None
             __repr__ = None
@@ -4214,13 +4229,12 @@ class SuggestionFormattingTestBase:
             actual = self.get_suggestion(cls(), 'bluch')
             self.assertIn("blech", actual)
 
-    def test_getattr_suggestions_for_same_name(self):
-        class A:
-            def __dir__(self):
-                return ['blech']
-        actual = self.get_suggestion(A(), 'blech')
-        self.assertNotIn("Did you mean", actual)
 
+class DelattrSuggestionTests(BaseSuggestionTests):
+    attr_function = delattr
+
+
+class SuggestionFormattingTestBase(SuggestionFormattingTestMixin):
     def test_attribute_error_with_failing_dict(self):
         class T:
             bluch = 1
@@ -4876,6 +4890,51 @@ class CPythonSuggestionFormattingTests(
     """
 
 
+class PurePythonGetattrSuggestionFormattingTests(
+    PurePythonExceptionFormattingMixin,
+    GetattrSuggestionTests,
+    unittest.TestCase,
+):
+    """
+    Same set of tests (for attribute access) as above using the pure Python
+    implementation of traceback printing in traceback.py.
+    """
+
+
+class PurePythonDelattrSuggestionFormattingTests(
+    PurePythonExceptionFormattingMixin,
+    DelattrSuggestionTests,
+    unittest.TestCase,
+):
+    """
+    Same set of tests (for attribute deletion) as above using the pure Python
+    implementation of traceback printing in traceback.py.
+    """
+
+
+@cpython_only
+class CPythonGetattrSuggestionFormattingTests(
+    CAPIExceptionFormattingMixin,
+    GetattrSuggestionTests,
+    unittest.TestCase,
+):
+    """
+    Same set of tests (for attribute access) as above but with Python's
+    internal traceback printing.
+    """
+
+
+@cpython_only
+class CPythonDelattrSuggestionFormattingTests(
+    CAPIExceptionFormattingMixin,
+    DelattrSuggestionTests,
+    unittest.TestCase,
+):
+    """
+    Same set of tests (for attribute deletion) as above but with Python's
+    internal traceback printing.
+    """
+
 class MiscTest(unittest.TestCase):
 
     def test_all(self):
diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2025-02-22-01-23-23.gh-issue-130425.x5SNQ8.rst b/Misc/NEWS.d/next/Core_and_Builtins/2025-02-22-01-23-23.gh-issue-130425.x5SNQ8.rst
new file mode 100644 (file)
index 0000000..a655cf2
--- /dev/null
@@ -0,0 +1,2 @@
+Add ``"Did you mean: 'attr'?"`` suggestion when using ``del obj.attr`` if ``attr``
+does not exist.
index 06e0c1b61cbcec8e8949b4c302f128d2d9ef712f..24188ffe7132d5b083d96cbbeef4aa40c21b08bd 100644 (file)
@@ -6983,6 +6983,7 @@ store_instance_attr_lock_held(PyObject *obj, PyDictValues *values,
         PyErr_Format(PyExc_AttributeError,
                         "'%.100s' object has no attribute '%U'",
                         Py_TYPE(obj)->tp_name, name);
+        (void)_PyObject_SetAttributeErrorContext(obj, name);
         return -1;
     }