Skip to content

Commit f27af8b

Browse files
Pranjal095sobolevnnedbattomasr8encukou
authored
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>
1 parent 4519b8a commit f27af8b

File tree

4 files changed

+98
-17
lines changed

4 files changed

+98
-17
lines changed

Doc/whatsnew/3.15.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,25 @@ Other language changes
239239
* Several error messages incorrectly using the term "argument" have been corrected.
240240
(Contributed by Stan Ulbrych in :gh:`133382`.)
241241

242+
* The interpreter now tries to provide a suggestion when
243+
:func:`delattr` fails due to a missing attribute.
244+
When an attribute name that closely resembles an existing attribute is used,
245+
the interpreter will suggest the correct attribute name in the error message.
246+
For example:
247+
248+
.. doctest::
249+
250+
>>> class A:
251+
... pass
252+
>>> a = A()
253+
>>> a.abcde = 1
254+
>>> del a.abcdf # doctest: +ELLIPSIS
255+
Traceback (most recent call last):
256+
...
257+
AttributeError: 'A' object has no attribute 'abcdf'. Did you mean: 'abcde'?
258+
259+
(Contributed by Nikita Sobolev and Pranjal Prajapati in :gh:`136588`.)
260+
242261
* Unraisable exceptions are now highlighted with color by default. This can be
243262
controlled by :ref:`environment variables <using-on-controlling-color>`.
244263
(Contributed by Peter Bierma in :gh:`134170`.)

Lib/test/test_traceback.py

Lines changed: 76 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4064,11 +4064,13 @@ def test_dont_swallow_subexceptions_of_falsey_exceptiongroup(self):
40644064
global_for_suggestions = None
40654065

40664066

4067-
class SuggestionFormattingTestBase:
4067+
class SuggestionFormattingTestMixin:
4068+
attr_function = getattr
4069+
40684070
def get_suggestion(self, obj, attr_name=None):
40694071
if attr_name is not None:
40704072
def callable():
4071-
getattr(obj, attr_name)
4073+
self.attr_function(obj, attr_name)
40724074
else:
40734075
callable = obj
40744076

@@ -4077,7 +4079,9 @@ def callable():
40774079
)
40784080
return result_lines[0]
40794081

4080-
def test_getattr_suggestions(self):
4082+
4083+
class BaseSuggestionTests(SuggestionFormattingTestMixin):
4084+
def test_suggestions(self):
40814085
class Substitution:
40824086
noise = more_noise = a = bc = None
40834087
blech = None
@@ -4120,18 +4124,19 @@ class CaseChangeOverSubstitution:
41204124
actual = self.get_suggestion(cls(), 'bluch')
41214125
self.assertIn(suggestion, actual)
41224126

4123-
def test_getattr_suggestions_underscored(self):
4127+
def test_suggestions_underscored(self):
41244128
class A:
41254129
bluch = None
41264130

41274131
self.assertIn("'bluch'", self.get_suggestion(A(), 'blach'))
41284132
self.assertIn("'bluch'", self.get_suggestion(A(), '_luch'))
41294133
self.assertIn("'bluch'", self.get_suggestion(A(), '_bluch'))
41304134

4135+
attr_function = self.attr_function
41314136
class B:
41324137
_bluch = None
41334138
def method(self, name):
4134-
getattr(self, name)
4139+
attr_function(self, name)
41354140

41364141
self.assertIn("'_bluch'", self.get_suggestion(B(), '_blach'))
41374142
self.assertIn("'_bluch'", self.get_suggestion(B(), '_luch'))
@@ -4141,28 +4146,29 @@ def method(self, name):
41414146
self.assertIn("'_bluch'", self.get_suggestion(partial(B().method, '_luch')))
41424147
self.assertIn("'_bluch'", self.get_suggestion(partial(B().method, 'bluch')))
41434148

4144-
def test_getattr_suggestions_do_not_trigger_for_long_attributes(self):
4149+
4150+
def test_do_not_trigger_for_long_attributes(self):
41454151
class A:
41464152
blech = None
41474153

41484154
actual = self.get_suggestion(A(), 'somethingverywrong')
41494155
self.assertNotIn("blech", actual)
41504156

4151-
def test_getattr_error_bad_suggestions_do_not_trigger_for_small_names(self):
4157+
def test_do_not_trigger_for_small_names(self):
41524158
class MyClass:
41534159
vvv = mom = w = id = pytho = None
41544160

41554161
for name in ("b", "v", "m", "py"):
41564162
with self.subTest(name=name):
4157-
actual = self.get_suggestion(MyClass, name)
4163+
actual = self.get_suggestion(MyClass(), name)
41584164
self.assertNotIn("Did you mean", actual)
41594165
self.assertNotIn("'vvv", actual)
41604166
self.assertNotIn("'mom'", actual)
41614167
self.assertNotIn("'id'", actual)
41624168
self.assertNotIn("'w'", actual)
41634169
self.assertNotIn("'pytho'", actual)
41644170

4165-
def test_getattr_suggestions_do_not_trigger_for_big_dicts(self):
4171+
def test_do_not_trigger_for_big_dicts(self):
41664172
class A:
41674173
blech = None
41684174
# A class with a very big __dict__ will not be considered
@@ -4173,7 +4179,16 @@ class A:
41734179
actual = self.get_suggestion(A(), 'bluch')
41744180
self.assertNotIn("blech", actual)
41754181

4176-
def test_getattr_suggestions_no_args(self):
4182+
def test_suggestions_for_same_name(self):
4183+
class A:
4184+
def __dir__(self):
4185+
return ['blech']
4186+
actual = self.get_suggestion(A(), 'blech')
4187+
self.assertNotIn("Did you mean", actual)
4188+
4189+
4190+
class GetattrSuggestionTests(BaseSuggestionTests):
4191+
def test_suggestions_no_args(self):
41774192
class A:
41784193
blech = None
41794194
def __getattr__(self, attr):
@@ -4190,7 +4205,7 @@ def __getattr__(self, attr):
41904205
actual = self.get_suggestion(A(), 'bluch')
41914206
self.assertIn("blech", actual)
41924207

4193-
def test_getattr_suggestions_invalid_args(self):
4208+
def test_suggestions_invalid_args(self):
41944209
class NonStringifyClass:
41954210
__str__ = None
41964211
__repr__ = None
@@ -4214,13 +4229,12 @@ def __getattr__(self, attr):
42144229
actual = self.get_suggestion(cls(), 'bluch')
42154230
self.assertIn("blech", actual)
42164231

4217-
def test_getattr_suggestions_for_same_name(self):
4218-
class A:
4219-
def __dir__(self):
4220-
return ['blech']
4221-
actual = self.get_suggestion(A(), 'blech')
4222-
self.assertNotIn("Did you mean", actual)
42234232

4233+
class DelattrSuggestionTests(BaseSuggestionTests):
4234+
attr_function = delattr
4235+
4236+
4237+
class SuggestionFormattingTestBase(SuggestionFormattingTestMixin):
42244238
def test_attribute_error_with_failing_dict(self):
42254239
class T:
42264240
bluch = 1
@@ -4876,6 +4890,51 @@ class CPythonSuggestionFormattingTests(
48764890
"""
48774891

48784892

4893+
class PurePythonGetattrSuggestionFormattingTests(
4894+
PurePythonExceptionFormattingMixin,
4895+
GetattrSuggestionTests,
4896+
unittest.TestCase,
4897+
):
4898+
"""
4899+
Same set of tests (for attribute access) as above using the pure Python
4900+
implementation of traceback printing in traceback.py.
4901+
"""
4902+
4903+
4904+
class PurePythonDelattrSuggestionFormattingTests(
4905+
PurePythonExceptionFormattingMixin,
4906+
DelattrSuggestionTests,
4907+
unittest.TestCase,
4908+
):
4909+
"""
4910+
Same set of tests (for attribute deletion) as above using the pure Python
4911+
implementation of traceback printing in traceback.py.
4912+
"""
4913+
4914+
4915+
@cpython_only
4916+
class CPythonGetattrSuggestionFormattingTests(
4917+
CAPIExceptionFormattingMixin,
4918+
GetattrSuggestionTests,
4919+
unittest.TestCase,
4920+
):
4921+
"""
4922+
Same set of tests (for attribute access) as above but with Python's
4923+
internal traceback printing.
4924+
"""
4925+
4926+
4927+
@cpython_only
4928+
class CPythonDelattrSuggestionFormattingTests(
4929+
CAPIExceptionFormattingMixin,
4930+
DelattrSuggestionTests,
4931+
unittest.TestCase,
4932+
):
4933+
"""
4934+
Same set of tests (for attribute deletion) as above but with Python's
4935+
internal traceback printing.
4936+
"""
4937+
48794938
class MiscTest(unittest.TestCase):
48804939

48814940
def test_all(self):
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add ``"Did you mean: 'attr'?"`` suggestion when using ``del obj.attr`` if ``attr``
2+
does not exist.

Objects/dictobject.c

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6983,6 +6983,7 @@ store_instance_attr_lock_held(PyObject *obj, PyDictValues *values,
69836983
PyErr_Format(PyExc_AttributeError,
69846984
"'%.100s' object has no attribute '%U'",
69856985
Py_TYPE(obj)->tp_name, name);
6986+
(void)_PyObject_SetAttributeErrorContext(obj, name);
69866987
return -1;
69876988
}
69886989

0 commit comments

Comments
 (0)