Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions Lib/test/test_traceback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
61 changes: 61 additions & 0 deletions Lib/traceback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, what needs to be suppressed here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing realistic - _get_cross_language_hint is just a dict lookup on (type(obj), wrong_name), both of which are already validated by the time we get here. I had it as a defensive measure since we're inside traceback formatting, but on reflection it's unnecessary given how simple the function is. Removed it.

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)
Expand Down Expand Up @@ -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.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's link to the GH issue instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, switched to the GH issue link.

_CROSS_LANGUAGE_HINTS = {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You might use a frozendict for such global constant dictionary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in f702e79 - wrapped in types.MappingProxyType.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need types.MappingProxyType. We have a native frozendict type in 3.15 as of PEP 814.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice - frozendict is cleaner than the MappingProxyType wrapper. Thanks for applying it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like the frozendict was lost in the meanwhile.

# list -- JavaScript/Ruby equivalents
(list, "push"): "append",
(list, "concat"): "extend",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer to keep standard convention and just use a single space after the :. Alignment adds extra maintenance (because we need to change each entry if we add something that breaks it) and also creates a false-symmetry between each entry (e.g., for our purposes (list, "push"): "append" has no functional relation to (list, "concat"): "extend").

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, dropped the alignment.

# 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:
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This last sentence isn't particularly useful:

Suggested change
``"".toUpperCase()`` suggests ``upper``. The ``list.add()`` case suggests
using a set instead, following feedback from the community discussion.
``"".toUpperCase()`` suggests ``upper``.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied your suggestion.

Loading