gh-146406: Add cross-language method suggestions for builtin AttributeError (#146407)

When Levenshtein-based suggestions find no match for an AttributeError
on list, str, or dict, check a static table of common method names from
JavaScript, Java, C#, and Ruby.

For example, [].push() now suggests .append(), "".toUpperCase() suggests
.upper(), and {}.keySet() suggests .keys().

The list.add() case suggests using a set instead of suggesting .append(),
since .add() is a set method and the user may have passed a list where
a set was expected (per discussion with Serhiy Storchaka, Terry Reedy,
and Paul Moore).

Design: flat (type, attr) -> suggestion text table, no runtime
introspection. Only exact builtin types are matched to avoid false
positives on subclasses.

Discussion: https://discuss.python.org/t/106632

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Victor Stinner <vstinner@python.org>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Van Horn
2026-05-04 14:38:07 -07:00
committed by GitHub
parent e89568f0cb
commit 7acee984e8
4 changed files with 224 additions and 6 deletions
+41
View File
@@ -492,6 +492,47 @@ 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?
These hints also work for subclasses of builtin types.
(Contributed by Matt Van Horn in :gh:`146406`.)
* The interpreter now 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,
+89
View File
@@ -4565,6 +4565,95 @@ class SuggestionFormattingTestBase(SuggestionFormattingTestMixin):
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_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)
+88 -6
View File
@@ -1187,12 +1187,20 @@ class TracebackException:
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)
@@ -1689,6 +1697,62 @@ _MAX_STRING_SIZE = 40
_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),),
})
def _substitution_cost(ch_a, ch_b):
if ch_a == ch_b:
@@ -1751,6 +1815,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.
@@ -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``.