gh-130425: Add "Did you mean [...]" suggestions for del obj.attr (GH-136588)

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>
This commit is contained in:
Pranjal Prajapati
2025-08-22 15:51:16 +05:30
committed by GitHub
parent 4519b8acb5
commit f27af8ba8e
4 changed files with 98 additions and 17 deletions

View 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`.)

View 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):

View File

@@ -0,0 +1,2 @@
Add ``"Did you mean: 'attr'?"`` suggestion when using ``del obj.attr`` if ``attr``
does not exist.

View 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;
}