From 7adad8b51b7a16a37ca2ab6dc6e7b6b09d7d2bd7 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 25 Mar 2026 00:13:35 -0700 Subject: [PATCH 01/14] Add cross-language method suggestions for builtin AttributeError 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 --- Lib/test/test_traceback.py | 61 +++++++++++++++++++ Lib/traceback.py | 61 +++++++++++++++++++ .../2026-03-25-cross-language-hints.rst | 5 ++ 3 files changed, 127 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-03-25-cross-language-hints.rst diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index 5dc11253e0d5c8..c81103e7d6fb58 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -4564,6 +4564,67 @@ def __init__(self): actual = self.get_suggestion(Outer(), 'target') self.assertIn("'.normal.target'", actual) + def test_cross_language_list_push_suggests_append(self): + actual = self.get_suggestion([], 'push') + self.assertIn("'.append'", actual) + + def test_cross_language_list_concat_suggests_extend(self): + actual = self.get_suggestion([], 'concat') + self.assertIn("'.extend'", actual) + + def test_cross_language_list_addAll_suggests_extend(self): + actual = self.get_suggestion([], 'addAll') + self.assertIn("'.extend'", actual) + + def test_cross_language_list_add_suggests_set(self): + actual = self.get_suggestion([], 'add') + self.assertIn("Did you mean to use a set?", actual) + + def test_cross_language_str_toUpperCase_suggests_upper(self): + actual = self.get_suggestion('', 'toUpperCase') + self.assertIn("'.upper'", actual) + + def test_cross_language_str_toLowerCase_suggests_lower(self): + actual = self.get_suggestion('', 'toLowerCase') + self.assertIn("'.lower'", actual) + + def test_cross_language_str_trimStart_suggests_lstrip(self): + actual = self.get_suggestion('', 'trimStart') + self.assertIn("'.lstrip'", actual) + + def test_cross_language_str_trimEnd_suggests_rstrip(self): + actual = self.get_suggestion('', 'trimEnd') + self.assertIn("'.rstrip'", actual) + + def test_cross_language_dict_keySet_suggests_keys(self): + actual = self.get_suggestion({}, 'keySet') + self.assertIn("'.keys'", actual) + + def test_cross_language_dict_entrySet_suggests_items(self): + actual = self.get_suggestion({}, 'entrySet') + self.assertIn("'.items'", actual) + + def test_cross_language_dict_putAll_suggests_update(self): + actual = self.get_suggestion({}, 'putAll') + self.assertIn("'.update'", actual) + + 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..5039c771670aa0 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -1153,6 +1153,11 @@ 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'): + with suppress(Exception): + 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 +1654,45 @@ 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) +# 3. Must be from a top-4 language by Python co-usage: JavaScript, Java, +# C#, or Ruby (JetBrains/PSF Developer Survey 2024) +# +# Each entry maps (builtin_type, wrong_name) to a suggestion string. +# If the suggestion is a Python method name, the standard "Did you mean" +# format is used. If it contains a space, it's rendered as a full hint. +# +# See https://discuss.python.org/t/106632 for the design discussion. +_CROSS_LANGUAGE_HINTS = { + # list -- JavaScript/Ruby equivalents + (list, "push"): "append", + (list, "concat"): "extend", + # list -- Java/C# equivalents + (list, "addAll"): "extend", + # list -- wrong-type suggestion (per Serhiy Storchaka, Terry Reedy, + # Paul Moore: list.add() more likely means the user expected a set) + (list, "add"): "Did you mean to use a set? Sets have an .add() method", + # str -- JavaScript equivalents + (str, "toUpperCase"): "upper", + (str, "toLowerCase"): "lower", + (str, "trimStart"): "lstrip", + (str, "trimEnd"): "rstrip", + # dict -- Java equivalents + (dict, "keySet"): "keys", + (dict, "entrySet"): "items", + (dict, "putAll"): "update", + # Note: indexOf, trim, and getOrDefault are not included because + # Levenshtein distance already catches them (indexOf->index, + # trim->strip, getOrDefault->setdefault). +} + def _substitution_cost(ch_a, ch_b): if ch_a == ch_b: @@ -1711,6 +1755,23 @@ 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. + """ + hint = _CROSS_LANGUAGE_HINTS.get((type(obj), wrong_name)) + if hint is None: + return None + if ' ' in hint: + # Full custom hint (e.g., wrong-type suggestion for list.add) + return hint + # Direct method equivalent -- format like Levenshtein suggestions + return f"Did you mean '.{hint}' instead of '.{wrong_name}'?" + + 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-cross-language-hints.rst b/Misc/NEWS.d/next/Library/2026-03-25-cross-language-hints.rst new file mode 100644 index 00000000000000..b21e5c07337b9f --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-03-25-cross-language-hints.rst @@ -0,0 +1,5 @@ +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`` (JavaScript), and +``"".toUpperCase()`` suggests ``upper``. The ``list.add()`` case suggests +using a set instead, following feedback from the community discussion. From aecaacbddbc79c9dd66c37338f4acc0d17e6331a Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 25 Mar 2026 00:18:37 -0700 Subject: [PATCH 02/14] fix NEWS entry filename for bedevere bot --- ...e-hints.rst => 2026-03-25-07-17-41.gh-issue-146406.ydsmqe.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Misc/NEWS.d/next/Library/{2026-03-25-cross-language-hints.rst => 2026-03-25-07-17-41.gh-issue-146406.ydsmqe.rst} (100%) diff --git a/Misc/NEWS.d/next/Library/2026-03-25-cross-language-hints.rst b/Misc/NEWS.d/next/Library/2026-03-25-07-17-41.gh-issue-146406.ydsmqe.rst similarity index 100% rename from Misc/NEWS.d/next/Library/2026-03-25-cross-language-hints.rst rename to Misc/NEWS.d/next/Library/2026-03-25-07-17-41.gh-issue-146406.ydsmqe.rst From 6d58cdc03e9ddf15128238c025de76fb3854d86f Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 25 Mar 2026 05:48:07 -0700 Subject: [PATCH 03/14] Address review feedback from @nedbat and @ZeroIntensity - Shorten hint format to "Did you mean '.append'?" (drop redundant "instead of '.push'" since the error already names the attribute) - Add dict.put and list.contains entries suggesting language constructs (dict[key] = value, x in list) per @ZeroIntensity's review - Replace suppress(Exception) with direct call (function is safe) - Link to GH issue instead of Discourse thread in comment - Drop column alignment in hint table entries - Trim NEWS entry last sentence --- Lib/test/test_traceback.py | 9 +++++ Lib/traceback.py | 33 ++++++++++--------- ...-03-25-07-17-41.gh-issue-146406.ydsmqe.rst | 5 ++- 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index c81103e7d6fb58..fd1a1399a4cc34 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -4567,6 +4567,7 @@ def __init__(self): def test_cross_language_list_push_suggests_append(self): actual = self.get_suggestion([], 'push') self.assertIn("'.append'", actual) + self.assertNotIn("instead of", actual) def test_cross_language_list_concat_suggests_extend(self): actual = self.get_suggestion([], 'concat') @@ -4576,6 +4577,10 @@ def test_cross_language_list_addAll_suggests_extend(self): actual = self.get_suggestion([], 'addAll') self.assertIn("'.extend'", actual) + def test_cross_language_list_contains_suggests_in(self): + actual = self.get_suggestion([], 'contains') + self.assertIn("Use 'x in list'", actual) + def test_cross_language_list_add_suggests_set(self): actual = self.get_suggestion([], 'add') self.assertIn("Did you mean to use a set?", actual) @@ -4608,6 +4613,10 @@ def test_cross_language_dict_putAll_suggests_update(self): actual = self.get_suggestion({}, 'putAll') self.assertIn("'.update'", actual) + def test_cross_language_dict_put_suggests_bracket(self): + actual = self.get_suggestion({}, 'put') + self.assertIn("dict[key] = value", actual) + def test_cross_language_levenshtein_takes_priority(self): # Levenshtein catches trim->strip and indexOf->index before # the cross-language table is consulted diff --git a/Lib/traceback.py b/Lib/traceback.py index 5039c771670aa0..15a772927b33ff 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -1154,10 +1154,9 @@ def __init__(self, exc_type, exc_value, exc_traceback, *, limit=None, else: self._str += f". Did you mean '.{suggestion}' ({suggestion!a}) instead of '.{wrong_name}' ({wrong_name!a})?" elif hasattr(exc_value, 'obj'): - with suppress(Exception): - hint = _get_cross_language_hint(exc_value.obj, wrong_name) - if hint: - self._str += f". {hint}" + 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) @@ -1669,25 +1668,27 @@ def print(self, *, file=None, chain=True, **kwargs): # If the suggestion is a Python method name, the standard "Did you mean" # format is used. If it contains a space, it's rendered as a full hint. # -# See https://discuss.python.org/t/106632 for the design discussion. +# See https://github.com/python/cpython/issues/146406 for the design discussion. _CROSS_LANGUAGE_HINTS = { # list -- JavaScript/Ruby equivalents - (list, "push"): "append", - (list, "concat"): "extend", + (list, "push"): "append", + (list, "concat"): "extend", # list -- Java/C# equivalents - (list, "addAll"): "extend", + (list, "addAll"): "extend", + (list, "contains"): "Use 'x in list' to check membership", # list -- wrong-type suggestion (per Serhiy Storchaka, Terry Reedy, # Paul Moore: list.add() more likely means the user expected a set) - (list, "add"): "Did you mean to use a set? Sets have an .add() method", + (list, "add"): "Did you mean to use a set? Sets have an .add() method", # str -- JavaScript equivalents (str, "toUpperCase"): "upper", (str, "toLowerCase"): "lower", - (str, "trimStart"): "lstrip", - (str, "trimEnd"): "rstrip", + (str, "trimStart"): "lstrip", + (str, "trimEnd"): "rstrip", # dict -- Java equivalents - (dict, "keySet"): "keys", - (dict, "entrySet"): "items", - (dict, "putAll"): "update", + (dict, "keySet"): "keys", + (dict, "entrySet"): "items", + (dict, "putAll"): "update", + (dict, "put"): "Use dict[key] = value for item assignment", # Note: indexOf, trim, and getOrDefault are not included because # Levenshtein distance already catches them (indexOf->index, # trim->strip, getOrDefault->setdefault). @@ -1768,8 +1769,8 @@ def _get_cross_language_hint(obj, wrong_name): if ' ' in hint: # Full custom hint (e.g., wrong-type suggestion for list.add) return hint - # Direct method equivalent -- format like Levenshtein suggestions - return f"Did you mean '.{hint}' instead of '.{wrong_name}'?" + # Direct method equivalent + return f"Did you mean '.{hint}'?" def _get_safe___dir__(obj): 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 index b21e5c07337b9f..2f9142ce905d28 100644 --- 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 @@ -1,5 +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`` (JavaScript), and -``"".toUpperCase()`` suggests ``upper``. The ``list.add()`` case suggests -using a set instead, following feedback from the community discussion. +For example, ``[].push()`` now suggests ``append`` and +``"".toUpperCase()`` suggests ``upper``. From 8a39e32eccf909e351b0353d823373a51fbb571d Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 25 Mar 2026 06:16:39 -0700 Subject: [PATCH 04/14] Add What's New entry for cross-language AttributeError hints --- Doc/whatsnew/3.15.rst | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 0973c387a1e595..fb671b6587021f 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 dict[key] = value for item assignment + + (Contributed by Matt Van Horn in :gh:`146406`.) + Other language changes ====================== From 196dbe4e8d4045ac34d8cdbbd2928ebd66feeda1 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 25 Mar 2026 07:06:48 -0700 Subject: [PATCH 05/14] Address review feedback from @picnixz - Use (hint, is_raw) tuples instead of space-based raw detection - Shorten list.add hint to "Did you mean to use a 'set' object?" - Use d[k] = v instead of dict[key] = value for dict.put hint - Add dict.entries -> items (JavaScript) - Remove Levenshtein guardrail from code comment (belongs on issue) - Add periods to raw hint messages - Add test for dict.entries --- Doc/whatsnew/3.15.rst | 2 +- Lib/test/test_traceback.py | 8 +++-- Lib/traceback.py | 60 +++++++++++++++++--------------------- 3 files changed, 34 insertions(+), 36 deletions(-) diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index fb671b6587021f..fb0c06c6f2edeb 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -445,7 +445,7 @@ Improved error messages >>> {}.put("a", 1) # doctest: +ELLIPSIS Traceback (most recent call last): ... - AttributeError: 'dict' object has no attribute 'put'. Use dict[key] = value for item assignment + AttributeError: 'dict' object has no attribute 'put'. Use d[k] = v for item assignment. (Contributed by Matt Van Horn in :gh:`146406`.) diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index fd1a1399a4cc34..d3ac8c9bd96465 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -4583,7 +4583,7 @@ def test_cross_language_list_contains_suggests_in(self): def test_cross_language_list_add_suggests_set(self): actual = self.get_suggestion([], 'add') - self.assertIn("Did you mean to use a set?", actual) + self.assertIn("Did you mean to use a 'set' object?", actual) def test_cross_language_str_toUpperCase_suggests_upper(self): actual = self.get_suggestion('', 'toUpperCase') @@ -4613,9 +4613,13 @@ def test_cross_language_dict_putAll_suggests_update(self): actual = self.get_suggestion({}, 'putAll') self.assertIn("'.update'", actual) + def test_cross_language_dict_entries_suggests_items(self): + actual = self.get_suggestion({}, 'entries') + self.assertIn("'.items'", actual) + def test_cross_language_dict_put_suggests_bracket(self): actual = self.get_suggestion({}, 'put') - self.assertIn("dict[key] = value", actual) + self.assertIn("d[k] = v", actual) def test_cross_language_levenshtein_takes_priority(self): # Levenshtein catches trim->strip and indexOf->index before diff --git a/Lib/traceback.py b/Lib/traceback.py index 15a772927b33ff..484c87b7194c2e 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -1658,40 +1658,35 @@ def print(self, *, file=None, chain=True, **kwargs): # # 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) -# 3. Must be from a top-4 language by Python co-usage: JavaScript, Java, -# C#, or Ruby (JetBrains/PSF Developer Survey 2024) +# traffic, bug reports in production repos, developer survey data). +# 2. Must be from a top-4 language by Python co-usage: JavaScript, Java, +# C#, or Ruby (JetBrains/PSF Developer Survey 2024). # -# Each entry maps (builtin_type, wrong_name) to a suggestion string. -# If the suggestion is a Python method name, the standard "Did you mean" -# format is used. If it contains a space, it's rendered as a full hint. +# 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 for the design discussion. +# See https://github.com/python/cpython/issues/146406. _CROSS_LANGUAGE_HINTS = { # list -- JavaScript/Ruby equivalents - (list, "push"): "append", - (list, "concat"): "extend", + (list, "push"): ("append", False), + (list, "concat"): ("extend", False), # list -- Java/C# equivalents - (list, "addAll"): "extend", - (list, "contains"): "Use 'x in list' to check membership", - # list -- wrong-type suggestion (per Serhiy Storchaka, Terry Reedy, - # Paul Moore: list.add() more likely means the user expected a set) - (list, "add"): "Did you mean to use a set? Sets have an .add() method", + (list, "addAll"): ("extend", False), + (list, "contains"): ("Use 'x in list' to check membership.", 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", - (str, "toLowerCase"): "lower", - (str, "trimStart"): "lstrip", - (str, "trimEnd"): "rstrip", - # dict -- Java equivalents - (dict, "keySet"): "keys", - (dict, "entrySet"): "items", - (dict, "putAll"): "update", - (dict, "put"): "Use dict[key] = value for item assignment", - # Note: indexOf, trim, and getOrDefault are not included because - # Levenshtein distance already catches them (indexOf->index, - # trim->strip, getOrDefault->setdefault). + (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 for item assignment.", True), } @@ -1763,13 +1758,12 @@ def _get_cross_language_hint(obj, wrong_name): positives on subclasses that may intentionally lack these methods. Returns a formatted hint string, or None. """ - hint = _CROSS_LANGUAGE_HINTS.get((type(obj), wrong_name)) - if hint is None: + entry = _CROSS_LANGUAGE_HINTS.get((type(obj), wrong_name)) + if entry is None: return None - if ' ' in hint: - # Full custom hint (e.g., wrong-type suggestion for list.add) + hint, is_raw = entry + if is_raw: return hint - # Direct method equivalent return f"Did you mean '.{hint}'?" From 99e106aefe73b97aca6c9508a5e030119874db58 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 25 Mar 2026 07:46:42 -0700 Subject: [PATCH 06/14] Keep Levenshtein criterion, remove survey restriction per picnixz --- Lib/traceback.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/traceback.py b/Lib/traceback.py index 484c87b7194c2e..51ff825246731a 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -1659,8 +1659,8 @@ def print(self, *, file=None, chain=True, **kwargs): # Inclusion criteria: # 1. Must have evidence of real cross-language confusion (Stack Overflow # traffic, bug reports in production repos, developer survey data). -# 2. Must be from a top-4 language by Python co-usage: JavaScript, Java, -# C#, or Ruby (JetBrains/PSF Developer Survey 2024). +# 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'?". From 57a4d399d69ce0e217552406a7d052a8c5bba6a0 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:08:52 -0700 Subject: [PATCH 07/14] Consolidate cross-language tests, shorten raw-message hints Address review feedback from @vstinner: - Merge 14 individual test_cross_language_* methods into a single parameterized test_cross_language using subTest - Shorten raw-message hints: "Use 'x in list'." and "Use d[k] = v." - Fix pre-existing levenshtein priority test assertion - Update whatsnew entry to match shortened hint text --- Doc/whatsnew/3.15.rst | 2 +- Lib/test/test_traceback.py | 79 ++++++++++++-------------------------- Lib/traceback.py | 4 +- 3 files changed, 27 insertions(+), 58 deletions(-) diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index fb0c06c6f2edeb..906c858aea7fa4 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -445,7 +445,7 @@ Improved error messages >>> {}.put("a", 1) # doctest: +ELLIPSIS Traceback (most recent call last): ... - AttributeError: 'dict' object has no attribute 'put'. Use d[k] = v for item assignment. + AttributeError: 'dict' object has no attribute 'put'. Use d[k] = v. (Contributed by Matt Van Horn in :gh:`146406`.) diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index d3ac8c9bd96465..7c7c5234bc1817 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -4564,68 +4564,37 @@ def __init__(self): actual = self.get_suggestion(Outer(), 'target') self.assertIn("'.normal.target'", actual) - def test_cross_language_list_push_suggests_append(self): + def test_cross_language(self): + cases = [ + # (obj, attr, expected_substring) + ([], 'push', "'.append'"), + ([], 'concat', "'.extend'"), + ([], 'addAll', "'.extend'"), + ([], 'contains', "Use 'x in list'."), + ([], 'add', "Did you mean to use a 'set' object?"), + ('', 'toUpperCase', "'.upper'"), + ('', 'toLowerCase', "'.lower'"), + ('', 'trimStart', "'.lstrip'"), + ('', 'trimEnd', "'.rstrip'"), + ({}, 'keySet', "'.keys'"), + ({}, 'entrySet', "'.items'"), + ({}, 'entries', "'.items'"), + ({}, 'putAll', "'.update'"), + ({}, 'put', "d[k] = v"), + ] + for obj, attr, expected in cases: + with self.subTest(type=type(obj).__name__, attr=attr): + actual = self.get_suggestion(obj, attr) + self.assertIn(expected, actual) + # push hint should not repeat the wrong attribute name actual = self.get_suggestion([], 'push') - self.assertIn("'.append'", actual) self.assertNotIn("instead of", actual) - def test_cross_language_list_concat_suggests_extend(self): - actual = self.get_suggestion([], 'concat') - self.assertIn("'.extend'", actual) - - def test_cross_language_list_addAll_suggests_extend(self): - actual = self.get_suggestion([], 'addAll') - self.assertIn("'.extend'", actual) - - def test_cross_language_list_contains_suggests_in(self): - actual = self.get_suggestion([], 'contains') - self.assertIn("Use 'x in list'", actual) - - def test_cross_language_list_add_suggests_set(self): - actual = self.get_suggestion([], 'add') - self.assertIn("Did you mean to use a 'set' object?", actual) - - def test_cross_language_str_toUpperCase_suggests_upper(self): - actual = self.get_suggestion('', 'toUpperCase') - self.assertIn("'.upper'", actual) - - def test_cross_language_str_toLowerCase_suggests_lower(self): - actual = self.get_suggestion('', 'toLowerCase') - self.assertIn("'.lower'", actual) - - def test_cross_language_str_trimStart_suggests_lstrip(self): - actual = self.get_suggestion('', 'trimStart') - self.assertIn("'.lstrip'", actual) - - def test_cross_language_str_trimEnd_suggests_rstrip(self): - actual = self.get_suggestion('', 'trimEnd') - self.assertIn("'.rstrip'", actual) - - def test_cross_language_dict_keySet_suggests_keys(self): - actual = self.get_suggestion({}, 'keySet') - self.assertIn("'.keys'", actual) - - def test_cross_language_dict_entrySet_suggests_items(self): - actual = self.get_suggestion({}, 'entrySet') - self.assertIn("'.items'", actual) - - def test_cross_language_dict_putAll_suggests_update(self): - actual = self.get_suggestion({}, 'putAll') - self.assertIn("'.update'", actual) - - def test_cross_language_dict_entries_suggests_items(self): - actual = self.get_suggestion({}, 'entries') - self.assertIn("'.items'", actual) - - def test_cross_language_dict_put_suggests_bracket(self): - actual = self.get_suggestion({}, 'put') - self.assertIn("d[k] = v", actual) - 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) + self.assertIn("strip", actual) def test_cross_language_no_hint_for_unknown_attr(self): actual = self.get_suggestion([], 'completely_unknown_method') diff --git a/Lib/traceback.py b/Lib/traceback.py index 51ff825246731a..dd692be355cb8a 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -1673,7 +1673,7 @@ def print(self, *, file=None, chain=True, **kwargs): (list, "concat"): ("extend", False), # list -- Java/C# equivalents (list, "addAll"): ("extend", False), - (list, "contains"): ("Use 'x in list' to check membership.", True), + (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 @@ -1686,7 +1686,7 @@ def print(self, *, file=None, chain=True, **kwargs): (dict, "entrySet"): ("items", False), (dict, "entries"): ("items", False), (dict, "putAll"): ("update", False), - (dict, "put"): ("Use d[k] = v for item assignment.", True), + (dict, "put"): ("Use d[k] = v.", True), } From 2a2c0d5a63b9b7cba8c1ff08060fe728d6048d5a Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:14:02 -0700 Subject: [PATCH 08/14] test: restructure test_cross_language to use assertEndsWith for full suffix matching Apply vstinner's review suggestion: use assertEndsWith instead of assertIn for more precise test assertions. Split cases into method hints (checked via Did you mean pattern) and raw hints (checked via exact suffix). --- Lib/test/test_traceback.py | 50 ++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index 7c7c5234bc1817..9c07bd3297f2de 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -4564,31 +4564,39 @@ def __init__(self): actual = self.get_suggestion(Outer(), 'target') self.assertIn("'.normal.target'", actual) + @force_not_colorized def test_cross_language(self): cases = [ - # (obj, attr, expected_substring) - ([], 'push', "'.append'"), - ([], 'concat', "'.extend'"), - ([], 'addAll', "'.extend'"), - ([], 'contains', "Use 'x in list'."), - ([], 'add', "Did you mean to use a 'set' object?"), - ('', 'toUpperCase', "'.upper'"), - ('', 'toLowerCase', "'.lower'"), - ('', 'trimStart', "'.lstrip'"), - ('', 'trimEnd', "'.rstrip'"), - ({}, 'keySet', "'.keys'"), - ({}, 'entrySet', "'.items'"), - ({}, 'entries', "'.items'"), - ({}, 'putAll', "'.update'"), - ({}, 'put', "d[k] = v"), + # (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 obj, attr, expected in cases: - with self.subTest(type=type(obj).__name__, attr=attr): + 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.assertIn(expected, actual) - # push hint should not repeat the wrong attribute name - actual = self.get_suggestion([], 'push') - self.assertNotIn("instead of", actual) + self.assertEndsWith(actual, expected) def test_cross_language_levenshtein_takes_priority(self): # Levenshtein catches trim->strip and indexOf->index before From f702e799b03ca270e51639b4b49a9a886d9cfb11 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:40:31 -0700 Subject: [PATCH 09/14] Use MappingProxyType for _CROSS_LANGUAGE_HINTS per vstinner review --- Lib/traceback.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/traceback.py b/Lib/traceback.py index dd692be355cb8a..dcd218e63239b5 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -1667,7 +1667,7 @@ def print(self, *, file=None, chain=True, **kwargs): # If is_raw is True, the suggestion is rendered as-is. # # See https://github.com/python/cpython/issues/146406. -_CROSS_LANGUAGE_HINTS = { +_CROSS_LANGUAGE_HINTS = types.MappingProxyType({ # list -- JavaScript/Ruby equivalents (list, "push"): ("append", False), (list, "concat"): ("extend", False), @@ -1687,7 +1687,7 @@ def print(self, *, file=None, chain=True, **kwargs): (dict, "entries"): ("items", False), (dict, "putAll"): ("update", False), (dict, "put"): ("Use d[k] = v.", True), -} +}) def _substitution_cost(ch_a, ch_b): From 3ad54c804a5826f6e32e8647f5b56b3cc0e99427 Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Thu, 26 Mar 2026 17:37:52 +0100 Subject: [PATCH 10/14] Update Lib/traceback.py --- Lib/traceback.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/traceback.py b/Lib/traceback.py index dcd218e63239b5..79ed0229859c77 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -1667,7 +1667,7 @@ def print(self, *, file=None, chain=True, **kwargs): # If is_raw is True, the suggestion is rendered as-is. # # See https://github.com/python/cpython/issues/146406. -_CROSS_LANGUAGE_HINTS = types.MappingProxyType({ +_CROSS_LANGUAGE_HINTS = frozendict({ # list -- JavaScript/Ruby equivalents (list, "push"): ("append", False), (list, "concat"): ("extend", False), From 00897611759cd026c2ed39a9704cc1ec89eb79ee Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 10 Apr 2026 07:59:37 -0700 Subject: [PATCH 11/14] Add blank line before criteria list per review Co-Authored-By: Claude Opus 4.6 (1M context) --- Lib/traceback.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/traceback.py b/Lib/traceback.py index 79ed0229859c77..587658c818e46c 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -1657,6 +1657,7 @@ def print(self, *, file=None, chain=True, **kwargs): # 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 From e2c12ec7b693bb1ce1c421a9f52a224812fc0a3f Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 10 Apr 2026 09:51:32 -0700 Subject: [PATCH 12/14] 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) --- Doc/whatsnew/3.15.rst | 22 ++++ Lib/test/test_traceback.py | 66 ++++++++++- Lib/traceback.py | 107 ++++++++++++------ ...-03-25-07-17-41.gh-issue-146406.ydsmqe.rst | 8 +- 4 files changed, 158 insertions(+), 45 deletions(-) diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 906c858aea7fa4..ceafa7d9c25dd3 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -447,6 +447,28 @@ Improved error messages ... 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? + + When a common method is called on ``None``, the hint suggests the type + the user likely expected: + + .. doctest:: + + >>> None.keys() # doctest: +ELLIPSIS + Traceback (most recent call last): + ... + AttributeError: 'NoneType' object has no attribute 'keys'. Did you expect a 'dict'? + + These hints also work for subclasses of builtin types. + (Contributed by Matt Van Horn in :gh:`146406`.) diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index 9c07bd3297f2de..325166a69774a7 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -4598,9 +4598,9 @@ def test_cross_language(self): 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 + 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) @@ -4608,12 +4608,66 @@ 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 + 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.assertNotIn("append", actual) + 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_none_suggestions(self): + # Common methods tried on None suggest the expected type + cases = [ + ('keys', "Did you expect a 'dict'?"), + ('values', "Did you expect a 'dict'?"), + ('items', "Did you expect a 'dict'?"), + ('upper', "Did you expect a 'str'?"), + ('lower', "Did you expect a 'str'?"), + ('strip', "Did you expect a 'str'?"), + ('split', "Did you expect a 'str'?"), + ('sort', "Did you expect a 'list'?"), + ('pop', "Did you expect a 'list' or 'dict'?"), + ] + for attr, expected in cases: + with self.subTest(attr=attr): + actual = self.get_suggestion(None, 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()) diff --git a/Lib/traceback.py b/Lib/traceback.py index 587658c818e46c..070fe169507dc5 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -1147,16 +1147,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})?" - elif hasattr(exc_value, 'obj'): + # 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}" + 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) @@ -1663,32 +1667,61 @@ def print(self, *, file=None, chain=True, **kwargs): # 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. +# 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({ +_CROSS_LANGUAGE_HINTS = { # list -- JavaScript/Ruby equivalents - (list, "push"): ("append", False), - (list, "concat"): ("extend", False), + "push": [(list, "append", False)], + "concat": [(list, "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), + "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 - (str, "toUpperCase"): ("upper", False), - (str, "toLowerCase"): ("lower", False), - (str, "trimStart"): ("lstrip", False), - (str, "trimEnd"): ("rstrip", False), + "toUpperCase": [(str, "upper", False)], + "toLowerCase": [(str, "lower", False)], + "trimStart": [(str, "lstrip", False)], + "trimEnd": [(str, "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), -}) + "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)], + # NoneType -- common methods tried on None (got None instead of expected type) + "keys": [(type(None), "Did you expect a 'dict'?", True)], + "values": [(type(None), "Did you expect a 'dict'?", True)], + "items": [(type(None), "Did you expect a 'dict'?", True)], + "upper": [(type(None), "Did you expect a 'str'?", True)], + "lower": [(type(None), "Did you expect a 'str'?", True)], + "strip": [(type(None), "Did you expect a 'str'?", True)], + "split": [(type(None), "Did you expect a 'str'?", True)], + "sort": [(type(None), "Did you expect a 'list'?", True)], + "pop": [(type(None), "Did you expect a 'list' or 'dict'?", True)], +} def _substitution_cost(ch_a, ch_b): @@ -1753,19 +1786,21 @@ def _check_for_nested_attribute(obj, wrong_name, attrs): def _get_cross_language_hint(obj, wrong_name): - """Check if wrong_name is a common method name from another language. + """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. - Only checks exact builtin types (list, str, dict) to avoid false - positives on subclasses that may intentionally lack these methods. + Uses isinstance() so subclasses of builtin types also get hints. Returns a formatted hint string, or None. """ - entry = _CROSS_LANGUAGE_HINTS.get((type(obj), wrong_name)) - if entry is None: + entries = _CROSS_LANGUAGE_HINTS.get(wrong_name) + if entries is None: return None - hint, is_raw = entry - if is_raw: - return hint - return f"Did you mean '.{hint}'?" + 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): 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 index 2f9142ce905d28..0f8107d2383ba9 100644 --- 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 @@ -1,4 +1,6 @@ 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``. +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``. From 564ca3a1b4470c4bb2e329b6fa99482551316048 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:55:04 -0400 Subject: [PATCH 13/14] Fix test_cross_language_works_for_subclasses CI failure Add missing @force_not_colorized decorator to three cross-language test methods. The CPython C-level traceback path includes ANSI color codes in error output, causing assertEndsWith to fail when the expected string doesn't account for trailing escape sequences. Also add the decorator to test_cross_language_levenshtein_fallback and test_cross_language_no_hint_for_unknown_attr for consistency with all other cross-language tests. --- Lib/test/test_traceback.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index 325166a69774a7..d8e6a4fdd823e9 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -4598,16 +4598,19 @@ def test_cross_language(self): 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): From e1eb6f45611dfc9718ea88dfb9dc161d2d8ee08f Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:18:05 -0400 Subject: [PATCH 14/14] Address vstinner review: frozendict wrapper, types.NoneType, doctest 1. Restore frozendict() wrapper for _CROSS_LANGUAGE_HINTS table (lost during the isinstance() refactor in e2c12ec). Convert inner lists to tuples for consistency with immutable container. 2. Replace type(None) with types.NoneType per review. 3. Replace contrived None.keys() doctest with a realistic example: lst = [3, 1, 2].sort() followed by lst.pop(), showing the common "method returned None" mistake vstinner suggested. --- Doc/whatsnew/3.15.rst | 10 +++--- Lib/traceback.py | 78 +++++++++++++++++++++---------------------- 2 files changed, 45 insertions(+), 43 deletions(-) diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index ceafa7d9c25dd3..8bfe9d4fab3a08 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -457,15 +457,17 @@ Improved error messages ... AttributeError: 'tuple' object has no attribute 'append'. Did you mean to use a 'list' object? - When a common method is called on ``None``, the hint suggests the type - the user likely expected: + When a common method is called on ``None`` (often because a method like + :meth:`list.sort` returned ``None`` instead of the expected object), the + hint suggests the type the user likely expected: .. doctest:: - >>> None.keys() # doctest: +ELLIPSIS + >>> lst = [3, 1, 2].sort() + >>> lst.pop() # doctest: +ELLIPSIS Traceback (most recent call last): ... - AttributeError: 'NoneType' object has no attribute 'keys'. Did you expect a 'dict'? + AttributeError: 'NoneType' object has no attribute 'pop'. Did you expect a 'list' or 'dict'? These hints also work for subclasses of builtin types. diff --git a/Lib/traceback.py b/Lib/traceback.py index 070fe169507dc5..874b5a6a1f5b53 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -1673,55 +1673,55 @@ def print(self, *, file=None, chain=True, **kwargs): # If is_raw is True, the suggestion is rendered as-is. # # See https://github.com/python/cpython/issues/146406. -_CROSS_LANGUAGE_HINTS = { +_CROSS_LANGUAGE_HINTS = frozendict({ # list -- JavaScript/Ruby equivalents - "push": [(list, "append", False)], - "concat": [(list, "extend", False)], + "push": ((list, "append", False),), + "concat": ((list, "extend", False),), # list -- Java/C# equivalents - "addAll": [(list, "extend", False)], - "contains": [(list, "Use 'x in list'.", True)], + "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)], + "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)], + "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)], + "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)], + "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)], + "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)], + "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)], + "__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),), # NoneType -- common methods tried on None (got None instead of expected type) - "keys": [(type(None), "Did you expect a 'dict'?", True)], - "values": [(type(None), "Did you expect a 'dict'?", True)], - "items": [(type(None), "Did you expect a 'dict'?", True)], - "upper": [(type(None), "Did you expect a 'str'?", True)], - "lower": [(type(None), "Did you expect a 'str'?", True)], - "strip": [(type(None), "Did you expect a 'str'?", True)], - "split": [(type(None), "Did you expect a 'str'?", True)], - "sort": [(type(None), "Did you expect a 'list'?", True)], - "pop": [(type(None), "Did you expect a 'list' or 'dict'?", True)], -} + "keys": ((types.NoneType, "Did you expect a 'dict'?", True),), + "values": ((types.NoneType, "Did you expect a 'dict'?", True),), + "items": ((types.NoneType, "Did you expect a 'dict'?", True),), + "upper": ((types.NoneType, "Did you expect a 'str'?", True),), + "lower": ((types.NoneType, "Did you expect a 'str'?", True),), + "strip": ((types.NoneType, "Did you expect a 'str'?", True),), + "split": ((types.NoneType, "Did you expect a 'str'?", True),), + "sort": ((types.NoneType, "Did you expect a 'list'?", True),), + "pop": ((types.NoneType, "Did you expect a 'list' or 'dict'?", True),), +}) def _substitution_cost(ch_a, ch_b):