Skip to content

Commit 7acee98

Browse files
mvanhornvstinnerclaude
authored
pythongh-146406: Add cross-language method suggestions for builtin AttributeError (python#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>
1 parent e89568f commit 7acee98

4 files changed

Lines changed: 224 additions & 6 deletions

File tree

Doc/whatsnew/3.15.rst

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,47 @@ Improved error messages
492492
^^^^^^^^^^^^^^
493493
AttributeError: 'Container' object has no attribute 'area'. Did you mean '.inner.area' instead of '.area'?
494494
495+
* When an :exc:`AttributeError` on a builtin type has no close match via
496+
Levenshtein distance, the error message now checks a static table of common
497+
method names from other languages (JavaScript, Java, Ruby, C#) and suggests
498+
the Python equivalent:
499+
500+
.. doctest::
501+
502+
>>> [1, 2, 3].push(4) # doctest: +ELLIPSIS
503+
Traceback (most recent call last):
504+
...
505+
AttributeError: 'list' object has no attribute 'push'. Did you mean '.append'?
506+
507+
>>> 'hello'.toUpperCase() # doctest: +ELLIPSIS
508+
Traceback (most recent call last):
509+
...
510+
AttributeError: 'str' object has no attribute 'toUpperCase'. Did you mean '.upper'?
511+
512+
When the Python equivalent is a language construct rather than a method,
513+
the hint describes the construct directly:
514+
515+
.. doctest::
516+
517+
>>> {}.put("a", 1) # doctest: +ELLIPSIS
518+
Traceback (most recent call last):
519+
...
520+
AttributeError: 'dict' object has no attribute 'put'. Use d[k] = v.
521+
522+
When a mutable method is called on an immutable type, the hint suggests
523+
the mutable counterpart:
524+
525+
.. doctest::
526+
527+
>>> (1, 2, 3).append(4) # doctest: +ELLIPSIS
528+
Traceback (most recent call last):
529+
...
530+
AttributeError: 'tuple' object has no attribute 'append'. Did you mean to use a 'list' object?
531+
532+
These hints also work for subclasses of builtin types.
533+
534+
(Contributed by Matt Van Horn in :gh:`146406`.)
535+
495536
* The interpreter now tries to provide a suggestion when
496537
:func:`delattr` fails due to a missing attribute.
497538
When an attribute name that closely resembles an existing attribute is used,

Lib/test/test_traceback.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4565,6 +4565,95 @@ def __init__(self):
45654565
actual = self.get_suggestion(Outer(), 'target')
45664566
self.assertIn("'.normal.target'", actual)
45674567

4568+
@force_not_colorized
4569+
def test_cross_language(self):
4570+
cases = [
4571+
# (type, attr, hint_attr)
4572+
(list, 'push', 'append'),
4573+
(list, 'concat', 'extend'),
4574+
(list, 'addAll', 'extend'),
4575+
(str, 'toUpperCase', 'upper'),
4576+
(str, 'toLowerCase', 'lower'),
4577+
(str, 'trimStart', 'lstrip'),
4578+
(str, 'trimEnd', 'rstrip'),
4579+
(dict, 'keySet', 'keys'),
4580+
(dict, 'entrySet', 'items'),
4581+
(dict, 'entries', 'items'),
4582+
(dict, 'putAll', 'update'),
4583+
]
4584+
for test_type, attr, hint_attr in cases:
4585+
with self.subTest(type=test_type.__name__, attr=attr):
4586+
obj = test_type()
4587+
actual = self.get_suggestion(obj, attr)
4588+
self.assertEndsWith(actual, f"Did you mean '.{hint_attr}'?")
4589+
4590+
cases = [
4591+
# (type, attr, hint)
4592+
(list, 'contains', "Use 'x in list'."),
4593+
(list, 'add', "Did you mean to use a 'set' object?"),
4594+
(dict, 'put', "Use d[k] = v."),
4595+
]
4596+
for test_type, attr, expected in cases:
4597+
with self.subTest(type=test_type, attr=attr):
4598+
obj = test_type()
4599+
actual = self.get_suggestion(obj, attr)
4600+
self.assertEndsWith(actual, expected)
4601+
4602+
@force_not_colorized
4603+
def test_cross_language_levenshtein_fallback(self):
4604+
# When no cross-language entry exists, Levenshtein still works
4605+
# (e.g., trim->strip is not in the table but Levenshtein catches it)
4606+
actual = self.get_suggestion('', 'trim')
4607+
self.assertIn("strip", actual)
4608+
4609+
@force_not_colorized
4610+
def test_cross_language_no_hint_for_unknown_attr(self):
4611+
actual = self.get_suggestion([], 'completely_unknown_method')
4612+
self.assertNotIn("Did you mean", actual)
4613+
4614+
@force_not_colorized
4615+
def test_cross_language_works_for_subclasses(self):
4616+
# isinstance() check means subclasses also get hints
4617+
class MyList(list):
4618+
pass
4619+
actual = self.get_suggestion(MyList(), 'push')
4620+
self.assertEndsWith(actual, "Did you mean '.append'?")
4621+
4622+
class MyDict(dict):
4623+
pass
4624+
actual = self.get_suggestion(MyDict(), 'keySet')
4625+
self.assertEndsWith(actual, "Did you mean '.keys'?")
4626+
4627+
@force_not_colorized
4628+
def test_cross_language_mutable_on_immutable(self):
4629+
# Mutable method on immutable type suggests the mutable counterpart
4630+
cases = [
4631+
(tuple, 'append', "Did you mean to use a 'list' object?"),
4632+
(tuple, 'extend', "Did you mean to use a 'list' object?"),
4633+
(tuple, 'insert', "Did you mean to use a 'list' object?"),
4634+
(tuple, 'remove', "Did you mean to use a 'list' object?"),
4635+
(frozenset, 'add', "Did you mean to use a 'set' object?"),
4636+
(frozenset, 'discard', "Did you mean to use a 'set' object?"),
4637+
(frozenset, 'remove', "Did you mean to use a 'set' object?"),
4638+
(frozenset, 'update', "Did you mean to use a 'set' object?"),
4639+
(frozendict, 'update', "Did you mean to use a 'dict' object?"),
4640+
]
4641+
for test_type, attr, expected in cases:
4642+
with self.subTest(type=test_type.__name__, attr=attr):
4643+
obj = test_type()
4644+
actual = self.get_suggestion(obj, attr)
4645+
self.assertEndsWith(actual, expected)
4646+
4647+
@force_not_colorized
4648+
def test_cross_language_float_bitwise(self):
4649+
# Bitwise operators on float suggest using int
4650+
cases = ['__or__', '__and__', '__xor__', '__lshift__', '__rshift__']
4651+
for attr in cases:
4652+
with self.subTest(attr=attr):
4653+
actual = self.get_suggestion(1.0, attr)
4654+
self.assertIn("'int'", actual)
4655+
self.assertIn("Bitwise operators", actual)
4656+
45684657
def make_module(self, code):
45694658
tmpdir = Path(tempfile.mkdtemp())
45704659
self.addCleanup(shutil.rmtree, tmpdir)

Lib/traceback.py

Lines changed: 88 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1187,12 +1187,20 @@ def __init__(self, exc_type, exc_value, exc_traceback, *, limit=None,
11871187
elif exc_type and issubclass(exc_type, AttributeError) and \
11881188
getattr(exc_value, "name", None) is not None:
11891189
wrong_name = getattr(exc_value, "name", None)
1190-
suggestion = _compute_suggestion_error(exc_value, exc_traceback, wrong_name)
1191-
if suggestion:
1192-
if suggestion.isascii():
1193-
self._str += f". Did you mean '.{suggestion}' instead of '.{wrong_name}'?"
1194-
else:
1195-
self._str += f". Did you mean '.{suggestion}' ({suggestion!a}) instead of '.{wrong_name}' ({wrong_name!a})?"
1190+
# Check cross-language/wrong-type hints first (more specific),
1191+
# then fall back to Levenshtein distance suggestions.
1192+
hint = None
1193+
if hasattr(exc_value, 'obj'):
1194+
hint = _get_cross_language_hint(exc_value.obj, wrong_name)
1195+
if hint:
1196+
self._str += f". {hint}"
1197+
else:
1198+
suggestion = _compute_suggestion_error(exc_value, exc_traceback, wrong_name)
1199+
if suggestion:
1200+
if suggestion.isascii():
1201+
self._str += f". Did you mean '.{suggestion}' instead of '.{wrong_name}'?"
1202+
else:
1203+
self._str += f". Did you mean '.{suggestion}' ({suggestion!a}) instead of '.{wrong_name}' ({wrong_name!a})?"
11961204
elif exc_type and issubclass(exc_type, NameError) and \
11971205
getattr(exc_value, "name", None) is not None:
11981206
wrong_name = getattr(exc_value, "name", None)
@@ -1689,6 +1697,62 @@ def print(self, *, file=None, chain=True, **kwargs):
16891697
_MOVE_COST = 2
16901698
_CASE_COST = 1
16911699

1700+
# Cross-language method suggestions for builtin types.
1701+
# Consulted as a fallback when Levenshtein-based suggestions find no match.
1702+
#
1703+
# Inclusion criteria:
1704+
#
1705+
# 1. Must have evidence of real cross-language confusion (Stack Overflow
1706+
# traffic, bug reports in production repos, developer survey data).
1707+
# 2. Must not be catchable by Levenshtein distance (too different from
1708+
# the correct Python method name).
1709+
#
1710+
# Each entry maps a wrong method name to a list of (type, suggestion, is_raw)
1711+
# tuples. The lookup checks isinstance() so subclasses are also matched.
1712+
# If is_raw is False, the suggestion is wrapped in "Did you mean '.X'?".
1713+
# If is_raw is True, the suggestion is rendered as-is.
1714+
#
1715+
# See https://github.com/python/cpython/issues/146406.
1716+
_CROSS_LANGUAGE_HINTS = frozendict({
1717+
# list -- JavaScript/Ruby equivalents
1718+
"push": ((list, "append", False),),
1719+
"concat": ((list, "extend", False),),
1720+
# list -- Java/C# equivalents
1721+
"addAll": ((list, "extend", False),),
1722+
"contains": ((list, "Use 'x in list'.", True),),
1723+
# list -- wrong-type suggestion (user expected a set)
1724+
"add": ((list, "Did you mean to use a 'set' object?", True),
1725+
(frozenset, "Did you mean to use a 'set' object?", True)),
1726+
# str -- JavaScript equivalents
1727+
"toUpperCase": ((str, "upper", False),),
1728+
"toLowerCase": ((str, "lower", False),),
1729+
"trimStart": ((str, "lstrip", False),),
1730+
"trimEnd": ((str, "rstrip", False),),
1731+
# dict -- Java/JavaScript equivalents
1732+
"keySet": ((dict, "keys", False),),
1733+
"entrySet": ((dict, "items", False),),
1734+
"entries": ((dict, "items", False),),
1735+
"putAll": ((dict, "update", False),),
1736+
"put": ((dict, "Use d[k] = v.", True),),
1737+
# tuple -- mutable method on immutable type (user expected a list)
1738+
"append": ((tuple, "Did you mean to use a 'list' object?", True),),
1739+
"extend": ((tuple, "Did you mean to use a 'list' object?", True),),
1740+
"insert": ((tuple, "Did you mean to use a 'list' object?", True),),
1741+
"remove": ((tuple, "Did you mean to use a 'list' object?", True),
1742+
(frozenset, "Did you mean to use a 'set' object?", True)),
1743+
# frozenset -- mutable method on immutable type (user expected a set)
1744+
"discard": ((frozenset, "Did you mean to use a 'set' object?", True),),
1745+
# frozendict -- mutable method on immutable type (user expected a dict)
1746+
"update": ((frozenset, "Did you mean to use a 'set' object?", True),
1747+
(frozendict, "Did you mean to use a 'dict' object?", True)),
1748+
# float -- bitwise operators belong to int
1749+
"__or__": ((float, "Did you mean to use an 'int' object? Bitwise operators are not supported by 'float'.", True),),
1750+
"__and__": ((float, "Did you mean to use an 'int' object? Bitwise operators are not supported by 'float'.", True),),
1751+
"__xor__": ((float, "Did you mean to use an 'int' object? Bitwise operators are not supported by 'float'.", True),),
1752+
"__lshift__": ((float, "Did you mean to use an 'int' object? Bitwise operators are not supported by 'float'.", True),),
1753+
"__rshift__": ((float, "Did you mean to use an 'int' object? Bitwise operators are not supported by 'float'.", True),),
1754+
})
1755+
16921756

16931757
def _substitution_cost(ch_a, ch_b):
16941758
if ch_a == ch_b:
@@ -1751,6 +1815,24 @@ def _check_for_nested_attribute(obj, wrong_name, attrs):
17511815
return None
17521816

17531817

1818+
def _get_cross_language_hint(obj, wrong_name):
1819+
"""Check if wrong_name is a common method name from another language,
1820+
a mutable method on an immutable type, or a method tried on None.
1821+
1822+
Uses isinstance() so subclasses of builtin types also get hints.
1823+
Returns a formatted hint string, or None.
1824+
"""
1825+
entries = _CROSS_LANGUAGE_HINTS.get(wrong_name)
1826+
if entries is None:
1827+
return None
1828+
for check_type, hint, is_raw in entries:
1829+
if isinstance(obj, check_type):
1830+
if is_raw:
1831+
return hint
1832+
return f"Did you mean '.{hint}'?"
1833+
return None
1834+
1835+
17541836
def _get_safe___dir__(obj):
17551837
# Use obj.__dir__() to avoid a TypeError when calling dir(obj).
17561838
# See gh-131001 and gh-139933.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Cross-language method suggestions are now shown for :exc:`AttributeError` on
2+
builtin types and their subclasses.
3+
For example, ``[].push()`` suggests ``append``,
4+
``(1,2).append(3)`` suggests using a ``list``,
5+
``None.keys()`` suggests expecting a ``dict``,
6+
and ``1.0.__or__`` suggests using an ``int``.

0 commit comments

Comments
 (0)