Skip to content
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

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

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
364 changes: 223 additions & 141 deletions Lib/test/test_traceback.py
Original file line number Diff line number Diff line change
@@ -3964,161 +3964,200 @@ def test_comparison(self):


class SuggestionFormattingTestBase:
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_getattr_suggestions(self):
class Substitution:
noise = more_noise = a = bc = None
blech = None
class BaseSuggestionTests:
"""
Subclasses need to implement the get_suggestion method.
"""
def test_suggestions(self):
class Substitution:
noise = more_noise = a = bc = None
blech = None

class Elimination:
noise = more_noise = a = bc = None
blch = None

class Addition:
noise = more_noise = a = bc = None
bluchin = None

class SubstitutionOverElimination:
blach = None
bluc = None

class SubstitutionOverAddition:
blach = None
bluchi = None

class EliminationOverAddition:
blucha = None
bluc = None

class CaseChangeOverSubstitution:
Luch = None
fluch = None
BLuch = None

for cls, suggestion in [
(Addition, "'bluchin'?"),
(Substitution, "'blech'?"),
(Elimination, "'blch'?"),
(Addition, "'bluchin'?"),
(SubstitutionOverElimination, "'blach'?"),
(SubstitutionOverAddition, "'blach'?"),
(EliminationOverAddition, "'bluc'?"),
(CaseChangeOverSubstitution, "'BLuch'?"),
]:
obj = cls()
actual = self.get_suggestion(obj, 'bluch')
self.assertIn(suggestion, actual)

def test_suggestions_underscored(self):
class A:
bluch = None

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 Elimination:
noise = more_noise = a = bc = None
blch = None

class Addition:
noise = more_noise = a = bc = None
bluchin = None

class SubstitutionOverElimination:
blach = None
bluc = None

class SubstitutionOverAddition:
blach = None
bluchi = None

class EliminationOverAddition:
blucha = None
bluc = None

class CaseChangeOverSubstitution:
Luch = None
fluch = None
BLuch = None

for cls, suggestion in [
(Addition, "'bluchin'?"),
(Substitution, "'blech'?"),
(Elimination, "'blch'?"),
(Addition, "'bluchin'?"),
(SubstitutionOverElimination, "'blach'?"),
(SubstitutionOverAddition, "'blach'?"),
(EliminationOverAddition, "'bluc'?"),
(CaseChangeOverSubstitution, "'BLuch'?"),
]:
actual = self.get_suggestion(cls(), 'bluch')
self.assertIn(suggestion, actual)

def test_getattr_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'))

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'))

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):
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):
class MyClass:
vvv = mom = w = id = pytho = None
class B:
_bluch = None
def method(self, name):
getattr(self, name)

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'))

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_do_not_trigger_for_long_attributes(self):
class A:
blech = None

obj = A()
actual = self.get_suggestion(obj, 'somethingverywrong')
self.assertNotIn("blech", actual)

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(obj, 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_do_not_trigger_for_big_dicts(self):
class A:
blech = None
# A class with a very big __dict__ will not be considered
# for suggestions.
for index in range(2000):
setattr(A, f"index_{index}", None)

obj = A()
actual = self.get_suggestion(obj, 'bluch')
self.assertNotIn("blech", actual)

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

for name in ("b", "v", "m", "py"):
with self.subTest(name=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)
result_lines = self.get_exception(
callable, slice_start=-1, slice_end=None
)
return result_lines[0]

def test_getattr_suggestions_do_not_trigger_for_big_dicts(self):
class A:
blech = None
# A class with a very big __dict__ will not be considered
# for suggestions.
for index in range(2000):
setattr(A, f"index_{index}", None)
def test_with_method_call(self):
# This is a placeholder method to make
# hasattr(self, 'test_with_method_call') return True
pass

actual = self.get_suggestion(A(), 'bluch')
self.assertNotIn("blech", actual)
def test_suggestions_no_args(self):
class A:
blech = None
def __getattr__(self, attr):
raise AttributeError()

def test_getattr_suggestions_no_args(self):
class A:
blech = None
def __getattr__(self, attr):
raise AttributeError()
actual = self.get_suggestion(A(), 'bluch')
self.assertIn("blech", actual)

actual = self.get_suggestion(A(), 'bluch')
self.assertIn("blech", actual)
class A:
blech = None
def __getattr__(self, attr):
raise AttributeError

class A:
blech = None
def __getattr__(self, attr):
raise AttributeError
actual = self.get_suggestion(A(), 'bluch')
self.assertIn("blech", actual)

actual = self.get_suggestion(A(), 'bluch')
self.assertIn("blech", actual)
def test_suggestions_invalid_args(self):
class NonStringifyClass:
__str__ = None
__repr__ = None

def test_getattr_suggestions_invalid_args(self):
class NonStringifyClass:
__str__ = None
__repr__ = None
class A:
blech = None
def __getattr__(self, attr):
raise AttributeError(NonStringifyClass())

class A:
blech = None
def __getattr__(self, attr):
raise AttributeError(NonStringifyClass())
class B:
blech = None
def __getattr__(self, attr):
raise AttributeError("Error", 23)

class B:
blech = None
def __getattr__(self, attr):
raise AttributeError("Error", 23)
class C:
blech = None
def __getattr__(self, attr):
raise AttributeError(23)

for cls in [A, B, C]:
actual = self.get_suggestion(cls(), 'bluch')
self.assertIn("blech", actual)

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)

class C:
blech = None
def __getattr__(self, attr):
raise AttributeError(23)
result_lines = self.get_exception(
callable, slice_start=-1, slice_end=None
)
return result_lines[0]

for cls in [A, B, C]:
actual = self.get_suggestion(cls(), 'bluch')
self.assertIn("blech", actual)
def get_suggestion(self, obj, attr_name=None):
if attr_name is not None:
def callable():
getattr(obj, attr_name)
else:
callable = obj

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)
result_lines = self.get_exception(
callable, slice_start=-1, slice_end=None
)
return result_lines[0]

def test_attribute_error_with_failing_dict(self):
class T:
@@ -4550,6 +4589,49 @@ class CPythonSuggestionFormattingTests(
"""


class PurePythonGetattrSuggestionFormattingTests(
PurePythonExceptionFormattingMixin,
SuggestionFormattingTestBase.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,
SuggestionFormattingTestBase.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,
SuggestionFormattingTestBase.GetattrSuggestionTests,
unittest.TestCase,
):
"""
Same set of tests (for attribute access) as above but with Python's internal traceback printing.
"""


@cpython_only
class CPythonDelattrSuggestionFormattingTests(
CAPIExceptionFormattingMixin,
SuggestionFormattingTestBase.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):
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.
Loading
Loading