Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions Doc/whatsnew/3.15.rst
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,24 @@ Other language changes
* Several error messages incorrectly using the term "argument" have been corrected.
(Contributed by Stan Ulbrych in :gh:`133382`.)

* The interpreter now provides a suggestion 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`.)


New modules
Expand Down
113 changes: 99 additions & 14 deletions Lib/test/test_traceback.py
Original file line number Diff line number Diff line change
Expand Up @@ -4021,7 +4021,7 @@ def test_dont_swallow_subexceptions_of_falsey_exceptiongroup(self):
global_for_suggestions = None


class SuggestionFormattingTestBase:
class SuggestionFormattingTestMixin:
def get_suggestion(self, obj, attr_name=None):
if attr_name is not None:
def callable():
Expand All @@ -4034,7 +4034,13 @@ def callable():
)
return result_lines[0]

def test_getattr_suggestions(self):

class BaseSuggestionTests(SuggestionFormattingTestMixin):
"""
Subclasses need to implement the get_suggestion method.
"""

def test_suggestions(self):
class Substitution:
noise = more_noise = a = bc = None
blech = None
Expand Down Expand Up @@ -4077,7 +4083,7 @@ class CaseChangeOverSubstitution:
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

Expand All @@ -4094,32 +4100,28 @@ def method(self, name):
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')))

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)
self.assertNotIn("'id'", actual)
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
Expand All @@ -4130,7 +4132,21 @@ class A:
actual = self.get_suggestion(A(), 'bluch')
self.assertNotIn("blech", actual)

def test_getattr_suggestions_no_args(self):

class GetattrSuggestionTests(BaseSuggestionTests):
def get_suggestion(self, obj, attr_name=None):
if attr_name is not None:
def callable():
getattr(obj, attr_name)
else:
callable = obj

result_lines = self.get_exception(
callable, slice_start=-1, slice_end=None
)
return result_lines[0]

def test_suggestions_no_args(self):
class A:
blech = None
def __getattr__(self, attr):
Expand All @@ -4147,7 +4163,7 @@ def __getattr__(self, attr):
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
Expand All @@ -4171,13 +4187,37 @@ def __getattr__(self, attr):
actual = self.get_suggestion(cls(), 'bluch')
self.assertIn("blech", actual)

def test_getattr_suggestions_for_same_name(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)

def test_suggestions_with_method_call(self):
class B:
_bluch = None
def method(self, name):
getattr(self, name)

obj = B()
self.assertIn("'_bluch'", self.get_suggestion(partial(obj.method, '_blach')))
self.assertIn("'_bluch'", self.get_suggestion(partial(obj.method, '_luch')))
self.assertIn("'_bluch'", self.get_suggestion(partial(obj.method, 'bluch')))


class DelattrSuggestionTests(BaseSuggestionTests):
def get_suggestion(self, obj, attr_name):
def callable():
delattr(obj, attr_name)

result_lines = self.get_exception(
callable, slice_start=-1, slice_end=None
)
return result_lines[0]


class SuggestionFormattingTestBase(SuggestionFormattingTestMixin):
def test_attribute_error_with_failing_dict(self):
class T:
bluch = 1
Expand Down Expand Up @@ -4655,6 +4695,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):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Add ``"Did you mean: 'attr'?"`` suggestion when using ``del obj.attr`` if ``attr``
does not exist.
1 change: 1 addition & 0 deletions Objects/dictobject.c
Original file line number Diff line number Diff line change
Expand Up @@ -6939,6 +6939,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;
}

Expand Down
Loading