diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 0973c387a1e595..906c858aea7fa4 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -420,6 +420,35 @@ 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. + + (Contributed by Matt Van Horn in :gh:`146406`.) + Other language changes ====================== diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index 5dc11253e0d5c8..9c07bd3297f2de 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -4564,6 +4564,57 @@ 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) + + def test_cross_language_levenshtein_takes_priority(self): + # Levenshtein catches trim->strip and indexOf->index before + # the cross-language table is consulted + actual = self.get_suggestion('', 'trim') + self.assertIn("strip", actual) + + def test_cross_language_no_hint_for_unknown_attr(self): + actual = self.get_suggestion([], 'completely_unknown_method') + self.assertNotIn("Did you mean", actual) + + def test_cross_language_not_triggered_for_subclasses(self): + # Only exact builtin types, not subclasses + class MyList(list): + pass + actual = self.get_suggestion(MyList(), 'push') + self.assertNotIn("append", actual) + def make_module(self, code): tmpdir = Path(tempfile.mkdtemp()) self.addCleanup(shutil.rmtree, tmpdir) diff --git a/Lib/traceback.py b/Lib/traceback.py index 1f9f151ebf5d39..dd692be355cb8a 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -1153,6 +1153,10 @@ def __init__(self, exc_type, exc_value, exc_traceback, *, limit=None, 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 hasattr(exc_value, 'obj'): + hint = _get_cross_language_hint(exc_value.obj, wrong_name) + if hint: + self._str += f". {hint}" elif exc_type and issubclass(exc_type, NameError) and \ getattr(exc_value, "name", None) is not None: wrong_name = getattr(exc_value, "name", None) @@ -1649,6 +1653,42 @@ 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 (builtin_type, wrong_name) to a (suggestion, is_raw) tuple. +# 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 = { + # list -- JavaScript/Ruby equivalents + (list, "push"): ("append", False), + (list, "concat"): ("extend", False), + # list -- Java/C# equivalents + (list, "addAll"): ("extend", False), + (list, "contains"): ("Use 'x in list'.", True), + # list -- wrong-type suggestion more likely means the user expected a set + (list, "add"): ("Did you mean to use a 'set' object?", True), + # str -- JavaScript equivalents + (str, "toUpperCase"): ("upper", False), + (str, "toLowerCase"): ("lower", False), + (str, "trimStart"): ("lstrip", False), + (str, "trimEnd"): ("rstrip", False), + # dict -- Java/JavaScript equivalents + (dict, "keySet"): ("keys", False), + (dict, "entrySet"): ("items", False), + (dict, "entries"): ("items", False), + (dict, "putAll"): ("update", False), + (dict, "put"): ("Use d[k] = v.", True), +} + def _substitution_cost(ch_a, ch_b): if ch_a == ch_b: @@ -1711,6 +1751,22 @@ 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. + + Only checks exact builtin types (list, str, dict) to avoid false + positives on subclasses that may intentionally lack these methods. + Returns a formatted hint string, or None. + """ + entry = _CROSS_LANGUAGE_HINTS.get((type(obj), wrong_name)) + if entry is None: + return None + hint, is_raw = entry + if is_raw: + return hint + return f"Did you mean '.{hint}'?" + + def _get_safe___dir__(obj): # Use obj.__dir__() to avoid a TypeError when calling dir(obj). # See gh-131001 and gh-139933. diff --git a/Misc/NEWS.d/next/Library/2026-03-25-07-17-41.gh-issue-146406.ydsmqe.rst b/Misc/NEWS.d/next/Library/2026-03-25-07-17-41.gh-issue-146406.ydsmqe.rst new file mode 100644 index 00000000000000..2f9142ce905d28 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-03-25-07-17-41.gh-issue-146406.ydsmqe.rst @@ -0,0 +1,4 @@ +Cross-language method suggestions are now shown for :exc:`AttributeError` on +builtin types when the existing Levenshtein-based suggestions find no match. +For example, ``[].push()`` now suggests ``append`` and +``"".toUpperCase()`` suggests ``upper``.