Skip to content

Commit e2c12ec

Browse files
mvanhornclaude
andcommitted
Address serhiy-storchaka feedback: isinstance, mutable/immutable, None, float
- Use isinstance() instead of type(obj) for hint lookup, so subclasses of builtin types (e.g. OrderedDict, custom list subclasses) also get cross-language hints. - Restructure table to index by method name for efficient isinstance iteration. - Add mutable-on-immutable hints: tuple.append/extend/insert/remove suggest list, frozenset.add/discard/remove/update suggest set, frozendict.update suggests dict. - Add NoneType hints: common methods (keys, upper, sort, etc.) tried on None suggest the type the user likely expected. - Add float bitwise hints: __or__/__and__/__xor__/__lshift__/__rshift__ suggest using int, fixing the misleading __dir__ Levenshtein suggestion. - Cross-language hints now take priority over Levenshtein (they are more specific; Levenshtein still fires as fallback when no table match). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0089761 commit e2c12ec

4 files changed

Lines changed: 158 additions & 45 deletions

File tree

Doc/whatsnew/3.15.rst

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,28 @@ Improved error messages
447447
...
448448
AttributeError: 'dict' object has no attribute 'put'. Use d[k] = v.
449449

450+
When a mutable method is called on an immutable type, the hint suggests
451+
the mutable counterpart:
452+
453+
.. doctest::
454+
455+
>>> (1, 2, 3).append(4) # doctest: +ELLIPSIS
456+
Traceback (most recent call last):
457+
...
458+
AttributeError: 'tuple' object has no attribute 'append'. Did you mean to use a 'list' object?
459+
460+
When a common method is called on ``None``, the hint suggests the type
461+
the user likely expected:
462+
463+
.. doctest::
464+
465+
>>> None.keys() # doctest: +ELLIPSIS
466+
Traceback (most recent call last):
467+
...
468+
AttributeError: 'NoneType' object has no attribute 'keys'. Did you expect a 'dict'?
469+
470+
These hints also work for subclasses of builtin types.
471+
450472
(Contributed by Matt Van Horn in :gh:`146406`.)
451473

452474

Lib/test/test_traceback.py

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4598,22 +4598,76 @@ def test_cross_language(self):
45984598
actual = self.get_suggestion(obj, attr)
45994599
self.assertEndsWith(actual, expected)
46004600

4601-
def test_cross_language_levenshtein_takes_priority(self):
4602-
# Levenshtein catches trim->strip and indexOf->index before
4603-
# the cross-language table is consulted
4601+
def test_cross_language_levenshtein_fallback(self):
4602+
# When no cross-language entry exists, Levenshtein still works
4603+
# (e.g., trim->strip is not in the table but Levenshtein catches it)
46044604
actual = self.get_suggestion('', 'trim')
46054605
self.assertIn("strip", actual)
46064606

46074607
def test_cross_language_no_hint_for_unknown_attr(self):
46084608
actual = self.get_suggestion([], 'completely_unknown_method')
46094609
self.assertNotIn("Did you mean", actual)
46104610

4611-
def test_cross_language_not_triggered_for_subclasses(self):
4612-
# Only exact builtin types, not subclasses
4611+
def test_cross_language_works_for_subclasses(self):
4612+
# isinstance() check means subclasses also get hints
46134613
class MyList(list):
46144614
pass
46154615
actual = self.get_suggestion(MyList(), 'push')
4616-
self.assertNotIn("append", actual)
4616+
self.assertEndsWith(actual, "Did you mean '.append'?")
4617+
4618+
class MyDict(dict):
4619+
pass
4620+
actual = self.get_suggestion(MyDict(), 'keySet')
4621+
self.assertEndsWith(actual, "Did you mean '.keys'?")
4622+
4623+
@force_not_colorized
4624+
def test_cross_language_mutable_on_immutable(self):
4625+
# Mutable method on immutable type suggests the mutable counterpart
4626+
cases = [
4627+
(tuple, 'append', "Did you mean to use a 'list' object?"),
4628+
(tuple, 'extend', "Did you mean to use a 'list' object?"),
4629+
(tuple, 'insert', "Did you mean to use a 'list' object?"),
4630+
(tuple, 'remove', "Did you mean to use a 'list' object?"),
4631+
(frozenset, 'add', "Did you mean to use a 'set' object?"),
4632+
(frozenset, 'discard', "Did you mean to use a 'set' object?"),
4633+
(frozenset, 'remove', "Did you mean to use a 'set' object?"),
4634+
(frozenset, 'update', "Did you mean to use a 'set' object?"),
4635+
(frozendict, 'update', "Did you mean to use a 'dict' object?"),
4636+
]
4637+
for test_type, attr, expected in cases:
4638+
with self.subTest(type=test_type.__name__, attr=attr):
4639+
obj = test_type()
4640+
actual = self.get_suggestion(obj, attr)
4641+
self.assertEndsWith(actual, expected)
4642+
4643+
@force_not_colorized
4644+
def test_cross_language_none_suggestions(self):
4645+
# Common methods tried on None suggest the expected type
4646+
cases = [
4647+
('keys', "Did you expect a 'dict'?"),
4648+
('values', "Did you expect a 'dict'?"),
4649+
('items', "Did you expect a 'dict'?"),
4650+
('upper', "Did you expect a 'str'?"),
4651+
('lower', "Did you expect a 'str'?"),
4652+
('strip', "Did you expect a 'str'?"),
4653+
('split', "Did you expect a 'str'?"),
4654+
('sort', "Did you expect a 'list'?"),
4655+
('pop', "Did you expect a 'list' or 'dict'?"),
4656+
]
4657+
for attr, expected in cases:
4658+
with self.subTest(attr=attr):
4659+
actual = self.get_suggestion(None, attr)
4660+
self.assertEndsWith(actual, expected)
4661+
4662+
@force_not_colorized
4663+
def test_cross_language_float_bitwise(self):
4664+
# Bitwise operators on float suggest using int
4665+
cases = ['__or__', '__and__', '__xor__', '__lshift__', '__rshift__']
4666+
for attr in cases:
4667+
with self.subTest(attr=attr):
4668+
actual = self.get_suggestion(1.0, attr)
4669+
self.assertIn("'int'", actual)
4670+
self.assertIn("Bitwise operators", actual)
46174671

46184672
def make_module(self, code):
46194673
tmpdir = Path(tempfile.mkdtemp())

Lib/traceback.py

Lines changed: 71 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1147,16 +1147,20 @@ def __init__(self, exc_type, exc_value, exc_traceback, *, limit=None,
11471147
elif exc_type and issubclass(exc_type, AttributeError) and \
11481148
getattr(exc_value, "name", None) is not None:
11491149
wrong_name = getattr(exc_value, "name", None)
1150-
suggestion = _compute_suggestion_error(exc_value, exc_traceback, wrong_name)
1151-
if suggestion:
1152-
if suggestion.isascii():
1153-
self._str += f". Did you mean '.{suggestion}' instead of '.{wrong_name}'?"
1154-
else:
1155-
self._str += f". Did you mean '.{suggestion}' ({suggestion!a}) instead of '.{wrong_name}' ({wrong_name!a})?"
1156-
elif hasattr(exc_value, 'obj'):
1150+
# Check cross-language/wrong-type hints first (more specific),
1151+
# then fall back to Levenshtein distance suggestions.
1152+
hint = None
1153+
if hasattr(exc_value, 'obj'):
11571154
hint = _get_cross_language_hint(exc_value.obj, wrong_name)
1158-
if hint:
1159-
self._str += f". {hint}"
1155+
if hint:
1156+
self._str += f". {hint}"
1157+
else:
1158+
suggestion = _compute_suggestion_error(exc_value, exc_traceback, wrong_name)
1159+
if suggestion:
1160+
if suggestion.isascii():
1161+
self._str += f". Did you mean '.{suggestion}' instead of '.{wrong_name}'?"
1162+
else:
1163+
self._str += f". Did you mean '.{suggestion}' ({suggestion!a}) instead of '.{wrong_name}' ({wrong_name!a})?"
11601164
elif exc_type and issubclass(exc_type, NameError) and \
11611165
getattr(exc_value, "name", None) is not None:
11621166
wrong_name = getattr(exc_value, "name", None)
@@ -1663,32 +1667,61 @@ def print(self, *, file=None, chain=True, **kwargs):
16631667
# 2. Must not be catchable by Levenshtein distance (too different from
16641668
# the correct Python method name).
16651669
#
1666-
# Each entry maps (builtin_type, wrong_name) to a (suggestion, is_raw) tuple.
1670+
# Each entry maps a wrong method name to a list of (type, suggestion, is_raw)
1671+
# tuples. The lookup checks isinstance() so subclasses are also matched.
16671672
# If is_raw is False, the suggestion is wrapped in "Did you mean '.X'?".
16681673
# If is_raw is True, the suggestion is rendered as-is.
16691674
#
16701675
# See https://github.com/python/cpython/issues/146406.
1671-
_CROSS_LANGUAGE_HINTS = frozendict({
1676+
_CROSS_LANGUAGE_HINTS = {
16721677
# list -- JavaScript/Ruby equivalents
1673-
(list, "push"): ("append", False),
1674-
(list, "concat"): ("extend", False),
1678+
"push": [(list, "append", False)],
1679+
"concat": [(list, "extend", False)],
16751680
# list -- Java/C# equivalents
1676-
(list, "addAll"): ("extend", False),
1677-
(list, "contains"): ("Use 'x in list'.", True),
1678-
# list -- wrong-type suggestion more likely means the user expected a set
1679-
(list, "add"): ("Did you mean to use a 'set' object?", True),
1681+
"addAll": [(list, "extend", False)],
1682+
"contains": [(list, "Use 'x in list'.", True)],
1683+
# list -- wrong-type suggestion (user expected a set)
1684+
"add": [(list, "Did you mean to use a 'set' object?", True),
1685+
(frozenset, "Did you mean to use a 'set' object?", True)],
16801686
# str -- JavaScript equivalents
1681-
(str, "toUpperCase"): ("upper", False),
1682-
(str, "toLowerCase"): ("lower", False),
1683-
(str, "trimStart"): ("lstrip", False),
1684-
(str, "trimEnd"): ("rstrip", False),
1687+
"toUpperCase": [(str, "upper", False)],
1688+
"toLowerCase": [(str, "lower", False)],
1689+
"trimStart": [(str, "lstrip", False)],
1690+
"trimEnd": [(str, "rstrip", False)],
16851691
# dict -- Java/JavaScript equivalents
1686-
(dict, "keySet"): ("keys", False),
1687-
(dict, "entrySet"): ("items", False),
1688-
(dict, "entries"): ("items", False),
1689-
(dict, "putAll"): ("update", False),
1690-
(dict, "put"): ("Use d[k] = v.", True),
1691-
})
1692+
"keySet": [(dict, "keys", False)],
1693+
"entrySet": [(dict, "items", False)],
1694+
"entries": [(dict, "items", False)],
1695+
"putAll": [(dict, "update", False)],
1696+
"put": [(dict, "Use d[k] = v.", True)],
1697+
# tuple -- mutable method on immutable type (user expected a list)
1698+
"append": [(tuple, "Did you mean to use a 'list' object?", True)],
1699+
"extend": [(tuple, "Did you mean to use a 'list' object?", True)],
1700+
"insert": [(tuple, "Did you mean to use a 'list' object?", True)],
1701+
"remove": [(tuple, "Did you mean to use a 'list' object?", True),
1702+
(frozenset, "Did you mean to use a 'set' object?", True)],
1703+
# frozenset -- mutable method on immutable type (user expected a set)
1704+
"discard": [(frozenset, "Did you mean to use a 'set' object?", True)],
1705+
# frozendict -- mutable method on immutable type (user expected a dict)
1706+
"update": [(frozenset, "Did you mean to use a 'set' object?", True),
1707+
(frozendict, "Did you mean to use a 'dict' object?", True)],
1708+
# float -- bitwise operators belong to int
1709+
"__or__": [(float, "Did you mean to use an 'int' object? Bitwise operators are not supported by 'float'.", True)],
1710+
"__and__": [(float, "Did you mean to use an 'int' object? Bitwise operators are not supported by 'float'.", True)],
1711+
"__xor__": [(float, "Did you mean to use an 'int' object? Bitwise operators are not supported by 'float'.", True)],
1712+
"__lshift__": [(float, "Did you mean to use an 'int' object? Bitwise operators are not supported by 'float'.", True)],
1713+
"__rshift__": [(float, "Did you mean to use an 'int' object? Bitwise operators are not supported by 'float'.", True)],
1714+
# NoneType -- common methods tried on None (got None instead of expected type)
1715+
"keys": [(type(None), "Did you expect a 'dict'?", True)],
1716+
"values": [(type(None), "Did you expect a 'dict'?", True)],
1717+
"items": [(type(None), "Did you expect a 'dict'?", True)],
1718+
"upper": [(type(None), "Did you expect a 'str'?", True)],
1719+
"lower": [(type(None), "Did you expect a 'str'?", True)],
1720+
"strip": [(type(None), "Did you expect a 'str'?", True)],
1721+
"split": [(type(None), "Did you expect a 'str'?", True)],
1722+
"sort": [(type(None), "Did you expect a 'list'?", True)],
1723+
"pop": [(type(None), "Did you expect a 'list' or 'dict'?", True)],
1724+
}
16921725

16931726

16941727
def _substitution_cost(ch_a, ch_b):
@@ -1753,19 +1786,21 @@ def _check_for_nested_attribute(obj, wrong_name, attrs):
17531786

17541787

17551788
def _get_cross_language_hint(obj, wrong_name):
1756-
"""Check if wrong_name is a common method name from another language.
1789+
"""Check if wrong_name is a common method name from another language,
1790+
a mutable method on an immutable type, or a method tried on None.
17571791
1758-
Only checks exact builtin types (list, str, dict) to avoid false
1759-
positives on subclasses that may intentionally lack these methods.
1792+
Uses isinstance() so subclasses of builtin types also get hints.
17601793
Returns a formatted hint string, or None.
17611794
"""
1762-
entry = _CROSS_LANGUAGE_HINTS.get((type(obj), wrong_name))
1763-
if entry is None:
1795+
entries = _CROSS_LANGUAGE_HINTS.get(wrong_name)
1796+
if entries is None:
17641797
return None
1765-
hint, is_raw = entry
1766-
if is_raw:
1767-
return hint
1768-
return f"Did you mean '.{hint}'?"
1798+
for check_type, hint, is_raw in entries:
1799+
if isinstance(obj, check_type):
1800+
if is_raw:
1801+
return hint
1802+
return f"Did you mean '.{hint}'?"
1803+
return None
17691804

17701805

17711806
def _get_safe___dir__(obj):
Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
Cross-language method suggestions are now shown for :exc:`AttributeError` on
2-
builtin types when the existing Levenshtein-based suggestions find no match.
3-
For example, ``[].push()`` now suggests ``append`` and
4-
``"".toUpperCase()`` suggests ``upper``.
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)