]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-146406: Add cross-language method suggestions for builtin AttributeError (#146407)
authorMatt Van Horn <mvanhorn@users.noreply.github.com>
Mon, 4 May 2026 21:38:07 +0000 (14:38 -0700)
committerGitHub <noreply@github.com>
Mon, 4 May 2026 21:38:07 +0000 (21:38 +0000)
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>
Doc/whatsnew/3.15.rst
Lib/test/test_traceback.py
Lib/traceback.py
Misc/NEWS.d/next/Library/2026-03-25-07-17-41.gh-issue-146406.ydsmqe.rst [new file with mode: 0644]

index b215c56408503af197b26afee3746505a3f9d00b..9409b41f574222bac73669809ac9e085435efb46 100644 (file)
@@ -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,
index 909808825f055e22a632f53912a8170cc9563e52..6624191f164bc189204c56fc0dee77ac43e28c4d 100644 (file)
@@ -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)
index 343d0e5f108c3522492beecdfb292804037e3e5a..66e88d0a588af33601a77bff07957c2acd7b0de5 100644 (file)
@@ -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.
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 (file)
index 0000000..0f8107d
--- /dev/null
@@ -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``.