Skip to content
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
53 changes: 53 additions & 0 deletions Doc/whatsnew/3.15.rst
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,59 @@ Improved error messages
^^^^^^^^^^^^^^
AttributeError: 'Container' object has no attribute 'area'. Did you mean '.inner.area' instead of '.area'?

* When an :exc:`AttributeError` on a builtin type has no close match via
Levenshtein distance, the error message now checks a static table of common
method names from other languages (JavaScript, Java, Ruby, C#) and suggests
the Python equivalent:

.. doctest::

>>> [1, 2, 3].push(4) # doctest: +ELLIPSIS
Traceback (most recent call last):
...
AttributeError: 'list' object has no attribute 'push'. Did you mean '.append'?

>>> 'hello'.toUpperCase() # doctest: +ELLIPSIS
Traceback (most recent call last):
...
AttributeError: 'str' object has no attribute 'toUpperCase'. Did you mean '.upper'?

When the Python equivalent is a language construct rather than a method,
the hint describes the construct directly:

.. doctest::

>>> {}.put("a", 1) # doctest: +ELLIPSIS
Traceback (most recent call last):
...
AttributeError: 'dict' object has no attribute 'put'. Use d[k] = v.

When a mutable method is called on an immutable type, the hint suggests
the mutable counterpart:

.. doctest::

>>> (1, 2, 3).append(4) # doctest: +ELLIPSIS
Traceback (most recent call last):
...
AttributeError: 'tuple' object has no attribute 'append'. Did you mean to use a 'list' object?

When a common method is called on ``None`` (often because a method like
:meth:`list.sort` returned ``None`` instead of the expected object), the
hint suggests the type the user likely expected:

.. doctest::

>>> lst = [3, 1, 2].sort()
>>> lst.pop() # doctest: +ELLIPSIS
Traceback (most recent call last):
...
AttributeError: 'NoneType' object has no attribute 'pop'. Did you expect a 'list' or 'dict'?

These hints also work for subclasses of builtin types.

(Contributed by Matt Van Horn in :gh:`146406`.)


Other language changes
======================
Expand Down
108 changes: 108 additions & 0 deletions Lib/test/test_traceback.py
Original file line number Diff line number Diff line change
Expand Up @@ -4564,6 +4564,114 @@ def __init__(self):
actual = self.get_suggestion(Outer(), 'target')
self.assertIn("'.normal.target'", actual)

@force_not_colorized
def test_cross_language(self):
cases = [
# (type, attr, hint_attr)
(list, 'push', 'append'),
(list, 'concat', 'extend'),
(list, 'addAll', 'extend'),
(str, 'toUpperCase', 'upper'),
(str, 'toLowerCase', 'lower'),
(str, 'trimStart', 'lstrip'),
(str, 'trimEnd', 'rstrip'),
(dict, 'keySet', 'keys'),
(dict, 'entrySet', 'items'),
(dict, 'entries', 'items'),
(dict, 'putAll', 'update'),
]
for test_type, attr, hint_attr in cases:
with self.subTest(type=test_type.__name__, attr=attr):
obj = test_type()
actual = self.get_suggestion(obj, attr)
self.assertEndsWith(actual, f"Did you mean '.{hint_attr}'?")

cases = [
# (type, attr, hint)
(list, 'contains', "Use 'x in list'."),
(list, 'add', "Did you mean to use a 'set' object?"),
(dict, 'put', "Use d[k] = v."),
]
for test_type, attr, expected in cases:
with self.subTest(type=test_type, attr=attr):
obj = test_type()
actual = self.get_suggestion(obj, attr)
self.assertEndsWith(actual, expected)

@force_not_colorized
def test_cross_language_levenshtein_fallback(self):
# When no cross-language entry exists, Levenshtein still works
# (e.g., trim->strip is not in the table but Levenshtein catches it)
actual = self.get_suggestion('', 'trim')
self.assertIn("strip", actual)

@force_not_colorized
def test_cross_language_no_hint_for_unknown_attr(self):
actual = self.get_suggestion([], 'completely_unknown_method')
self.assertNotIn("Did you mean", actual)

@force_not_colorized
def test_cross_language_works_for_subclasses(self):
# isinstance() check means subclasses also get hints
class MyList(list):
pass
actual = self.get_suggestion(MyList(), 'push')
self.assertEndsWith(actual, "Did you mean '.append'?")

class MyDict(dict):
pass
actual = self.get_suggestion(MyDict(), 'keySet')
self.assertEndsWith(actual, "Did you mean '.keys'?")

@force_not_colorized
def test_cross_language_mutable_on_immutable(self):
# Mutable method on immutable type suggests the mutable counterpart
cases = [
(tuple, 'append', "Did you mean to use a 'list' object?"),
(tuple, 'extend', "Did you mean to use a 'list' object?"),
(tuple, 'insert', "Did you mean to use a 'list' object?"),
(tuple, 'remove', "Did you mean to use a 'list' object?"),
(frozenset, 'add', "Did you mean to use a 'set' object?"),
(frozenset, 'discard', "Did you mean to use a 'set' object?"),
(frozenset, 'remove', "Did you mean to use a 'set' object?"),
(frozenset, 'update', "Did you mean to use a 'set' object?"),
(frozendict, 'update', "Did you mean to use a 'dict' object?"),
]
for test_type, attr, expected in cases:
with self.subTest(type=test_type.__name__, attr=attr):
obj = test_type()
actual = self.get_suggestion(obj, attr)
self.assertEndsWith(actual, expected)

@force_not_colorized
def test_cross_language_none_suggestions(self):
# Common methods tried on None suggest the expected type
cases = [
('keys', "Did you expect a 'dict'?"),
('values', "Did you expect a 'dict'?"),
('items', "Did you expect a 'dict'?"),
('upper', "Did you expect a 'str'?"),
('lower', "Did you expect a 'str'?"),
('strip', "Did you expect a 'str'?"),
('split', "Did you expect a 'str'?"),
('sort', "Did you expect a 'list'?"),
('pop', "Did you expect a 'list' or 'dict'?"),
]
for attr, expected in cases:
with self.subTest(attr=attr):
actual = self.get_suggestion(None, attr)
self.assertEndsWith(actual, expected)

@force_not_colorized
def test_cross_language_float_bitwise(self):
# Bitwise operators on float suggest using int
cases = ['__or__', '__and__', '__xor__', '__lshift__', '__rshift__']
for attr in cases:
with self.subTest(attr=attr):
actual = self.get_suggestion(1.0, attr)
self.assertIn("'int'", actual)
self.assertIn("Bitwise operators", actual)

def make_module(self, code):
tmpdir = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, tmpdir)
Expand Down
104 changes: 98 additions & 6 deletions Lib/traceback.py
Original file line number Diff line number Diff line change
Expand Up @@ -1147,12 +1147,20 @@ def __init__(self, exc_type, exc_value, exc_traceback, *, limit=None,
elif exc_type and issubclass(exc_type, AttributeError) and \
getattr(exc_value, "name", None) is not None:
wrong_name = getattr(exc_value, "name", None)
suggestion = _compute_suggestion_error(exc_value, exc_traceback, wrong_name)
if suggestion:
if suggestion.isascii():
self._str += f". Did you mean '.{suggestion}' instead of '.{wrong_name}'?"
else:
self._str += f". Did you mean '.{suggestion}' ({suggestion!a}) instead of '.{wrong_name}' ({wrong_name!a})?"
# Check cross-language/wrong-type hints first (more specific),
# then fall back to Levenshtein distance suggestions.
hint = None
if hasattr(exc_value, 'obj'):
hint = _get_cross_language_hint(exc_value.obj, wrong_name)
if hint:
self._str += f". {hint}"
else:
suggestion = _compute_suggestion_error(exc_value, exc_traceback, wrong_name)
if suggestion:
if suggestion.isascii():
self._str += f". Did you mean '.{suggestion}' instead of '.{wrong_name}'?"
else:
self._str += f". Did you mean '.{suggestion}' ({suggestion!a}) instead of '.{wrong_name}' ({wrong_name!a})?"
elif exc_type and issubclass(exc_type, NameError) and \
getattr(exc_value, "name", None) is not None:
wrong_name = getattr(exc_value, "name", None)
Expand Down Expand Up @@ -1649,6 +1657,72 @@ def print(self, *, file=None, chain=True, **kwargs):
_MOVE_COST = 2
_CASE_COST = 1

# Cross-language method suggestions for builtin types.
# Consulted as a fallback when Levenshtein-based suggestions find no match.
#
# Inclusion criteria:
#
# 1. Must have evidence of real cross-language confusion (Stack Overflow
# traffic, bug reports in production repos, developer survey data).
# 2. Must not be catchable by Levenshtein distance (too different from
# the correct Python method name).
#
# Each entry maps a wrong method name to a list of (type, suggestion, is_raw)
# tuples. The lookup checks isinstance() so subclasses are also matched.
# If is_raw is False, the suggestion is wrapped in "Did you mean '.X'?".
# If is_raw is True, the suggestion is rendered as-is.
#
# See https://github.com/python/cpython/issues/146406.
_CROSS_LANGUAGE_HINTS = frozendict({
# list -- JavaScript/Ruby equivalents
"push": ((list, "append", False),),
"concat": ((list, "extend", False),),
# list -- Java/C# equivalents
"addAll": ((list, "extend", False),),
"contains": ((list, "Use 'x in list'.", True),),
# list -- wrong-type suggestion (user expected a set)
"add": ((list, "Did you mean to use a 'set' object?", True),
(frozenset, "Did you mean to use a 'set' object?", True)),
# str -- JavaScript equivalents
"toUpperCase": ((str, "upper", False),),
"toLowerCase": ((str, "lower", False),),
"trimStart": ((str, "lstrip", False),),
"trimEnd": ((str, "rstrip", False),),
# dict -- Java/JavaScript equivalents
"keySet": ((dict, "keys", False),),
"entrySet": ((dict, "items", False),),
"entries": ((dict, "items", False),),
"putAll": ((dict, "update", False),),
"put": ((dict, "Use d[k] = v.", True),),
# tuple -- mutable method on immutable type (user expected a list)
"append": ((tuple, "Did you mean to use a 'list' object?", True),),
"extend": ((tuple, "Did you mean to use a 'list' object?", True),),
"insert": ((tuple, "Did you mean to use a 'list' object?", True),),
"remove": ((tuple, "Did you mean to use a 'list' object?", True),
(frozenset, "Did you mean to use a 'set' object?", True)),
# frozenset -- mutable method on immutable type (user expected a set)
"discard": ((frozenset, "Did you mean to use a 'set' object?", True),),
# frozendict -- mutable method on immutable type (user expected a dict)
"update": ((frozenset, "Did you mean to use a 'set' object?", True),
(frozendict, "Did you mean to use a 'dict' object?", True)),
# float -- bitwise operators belong to int
"__or__": ((float, "Did you mean to use an 'int' object? Bitwise operators are not supported by 'float'.", True),),
"__and__": ((float, "Did you mean to use an 'int' object? Bitwise operators are not supported by 'float'.", True),),
"__xor__": ((float, "Did you mean to use an 'int' object? Bitwise operators are not supported by 'float'.", True),),
"__lshift__": ((float, "Did you mean to use an 'int' object? Bitwise operators are not supported by 'float'.", True),),
"__rshift__": ((float, "Did you mean to use an 'int' object? Bitwise operators are not supported by 'float'.", True),),
# NoneType -- common methods tried on None (got None instead of expected type)
"keys": ((types.NoneType, "Did you expect a 'dict'?", True),),
"values": ((types.NoneType, "Did you expect a 'dict'?", True),),
"items": ((types.NoneType, "Did you expect a 'dict'?", True),),
"upper": ((types.NoneType, "Did you expect a 'str'?", True),),
"lower": ((types.NoneType, "Did you expect a 'str'?", True),),
"strip": ((types.NoneType, "Did you expect a 'str'?", True),),
"split": ((types.NoneType, "Did you expect a 'str'?", True),),
"sort": ((types.NoneType, "Did you expect a 'list'?", True),),
"pop": ((types.NoneType, "Did you expect a 'list' or 'dict'?", True),),
})


def _substitution_cost(ch_a, ch_b):
if ch_a == ch_b:
Expand Down Expand Up @@ -1711,6 +1785,24 @@ def _check_for_nested_attribute(obj, wrong_name, attrs):
return None


def _get_cross_language_hint(obj, wrong_name):
"""Check if wrong_name is a common method name from another language,
a mutable method on an immutable type, or a method tried on None.

Uses isinstance() so subclasses of builtin types also get hints.
Returns a formatted hint string, or None.
"""
entries = _CROSS_LANGUAGE_HINTS.get(wrong_name)
if entries is None:
return None
for check_type, hint, is_raw in entries:
if isinstance(obj, check_type):
if is_raw:
return hint
return f"Did you mean '.{hint}'?"
return None


def _get_safe___dir__(obj):
# Use obj.__dir__() to avoid a TypeError when calling dir(obj).
# See gh-131001 and gh-139933.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Cross-language method suggestions are now shown for :exc:`AttributeError` on
builtin types and their subclasses.
For example, ``[].push()`` suggests ``append``,
``(1,2).append(3)`` suggests using a ``list``,
``None.keys()`` suggests expecting a ``dict``,
and ``1.0.__or__`` suggests using an ``int``.
Loading