https://github.com/python/cpython/commit/7acee984e8e2a88bcfb7a83e9c472902e340e5be
commit: 7acee984e8e2a88bcfb7a83e9c472902e340e5be
branch: main
author: Matt Van Horn <[email protected]>
committer: vstinner <[email protected]>
date: 2026-05-04T21:38:07Z
summary:

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 <[email protected]>
Co-authored-by: Victor Stinner <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>

files:
A Misc/NEWS.d/next/Library/2026-03-25-07-17-41.gh-issue-146406.ydsmqe.rst
M Doc/whatsnew/3.15.rst
M Lib/test/test_traceback.py
M Lib/traceback.py

diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst
index b215c56408503a..9409b41f574222 100644
--- a/Doc/whatsnew/3.15.rst
+++ b/Doc/whatsnew/3.15.rst
@@ -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,
diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py
index 909808825f055e..6624191f164bc1 100644
--- a/Lib/test/test_traceback.py
+++ b/Lib/test/test_traceback.py
@@ -4565,6 +4565,95 @@ 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)
+
+    @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)
diff --git a/Lib/traceback.py b/Lib/traceback.py
index 343d0e5f108c35..66e88d0a588af3 100644
--- a/Lib/traceback.py
+++ b/Lib/traceback.py
@@ -1187,12 +1187,20 @@ def __init__(self, exc_type, exc_value, exc_traceback, 
*, limit=None,
         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 @@ 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 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.
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..0f8107d2383ba9
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-03-25-07-17-41.gh-issue-146406.ydsmqe.rst
@@ -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``.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to