mirror of
https://github.com/python/cpython.git
synced 2026-05-06 12:49:07 -04:00
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:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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``.
|
||||
Reference in New Issue
Block a user