diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index 74b979d009664d..bdc34a29022ffe 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -4020,8 +4020,7 @@ def test_dont_swallow_subexceptions_of_falsey_exceptiongroup(self): global_for_suggestions = None - -class SuggestionFormattingTestBase: +class SuggestionFormattingTestBaseParent: def get_suggestion(self, obj, attr_name=None): if attr_name is not None: def callable(): @@ -4033,8 +4032,12 @@ def callable(): callable, slice_start=-1, slice_end=None ) return result_lines[0] - - def test_getattr_suggestions(self): + +class BaseSuggestionTests(SuggestionFormattingTestBaseParent): + """ + Subclasses need to implement the get_suggestion method. + """ + def test_suggestions(self): class Substitution: noise = more_noise = a = bc = None blech = None @@ -4074,44 +4077,50 @@ class CaseChangeOverSubstitution: (EliminationOverAddition, "'bluc'?"), (CaseChangeOverSubstitution, "'BLuch'?"), ]: - actual = self.get_suggestion(cls(), 'bluch') + obj = cls() + actual = self.get_suggestion(obj, 'bluch') self.assertIn(suggestion, actual) - def test_getattr_suggestions_underscored(self): + 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')) + obj = A() + self.assertIn("'bluch'", self.get_suggestion(obj, 'blach')) + self.assertIn("'bluch'", self.get_suggestion(obj, '_luch')) + self.assertIn("'bluch'", self.get_suggestion(obj, '_bluch')) class B: _bluch = None def method(self, name): getattr(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')) + obj = B() + self.assertIn("'_bluch'", self.get_suggestion(obj, '_blach')) + self.assertIn("'_bluch'", self.get_suggestion(obj, '_luch')) + self.assertNotIn("'_bluch'", self.get_suggestion(obj, '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'))) + if hasattr(self, 'test_with_method_call'): + 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'))) - 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') + obj = A() + actual = self.get_suggestion(obj, '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 + obj = MyClass() for name in ("b", "v", "m", "py"): with self.subTest(name=name): - actual = self.get_suggestion(MyClass, name) + actual = self.get_suggestion(obj, name) self.assertNotIn("Did you mean", actual) self.assertNotIn("'vvv", actual) self.assertNotIn("'mom'", actual) @@ -4119,7 +4128,7 @@ class MyClass: 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 @@ -4127,10 +4136,29 @@ class A: for index in range(2000): setattr(A, f"index_{index}", None) - actual = self.get_suggestion(A(), 'bluch') - self.assertNotIn("blech", actual) + obj = A() + actual = self.get_suggestion(obj, '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_with_method_call(self): + # This is a placeholder method to make + # hasattr(self, 'test_with_method_call') return True + pass + + def test_suggestions_no_args(self): class A: blech = None def __getattr__(self, attr): @@ -4147,7 +4175,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 @@ -4171,13 +4199,24 @@ 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) +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(SuggestionFormattingTestBaseParent): def test_attribute_error_with_failing_dict(self): class T: bluch = 1 @@ -4655,6 +4694,49 @@ 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 index 00000000000000..a655cf2f2a765b --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2025-02-22-01-23-23.gh-issue-130425.x5SNQ8.rst @@ -0,0 +1,2 @@ +Add ``"Did you mean: 'attr'?"`` suggestion when using ``del obj.attr`` if ``attr`` +does not exist. diff --git a/Objects/dictobject.c b/Objects/dictobject.c index be62ae5eefd00d..f8e63a9315d9c0 100644 --- a/Objects/dictobject.c +++ b/Objects/dictobject.c @@ -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); + _PyObject_SetAttributeErrorContext(obj, name); return -1; }