Skip to content

Commit ebb4582

Browse files
authored
Backported the pure Python suggestion code from 3.12 (#42)
Fixes #41. This was done to prevent NameError/AttributeError suggestions from breaking when using exceptiongroup on 3.10 (and on PyPy).
1 parent 5bf3692 commit ebb4582

File tree

2 files changed

+181
-0
lines changed

2 files changed

+181
-0
lines changed

src/exceptiongroup/_formatting.py

+132
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,14 @@ def __init__(
117117
end_lno = exc_value.end_lineno
118118
self.end_lineno = str(end_lno) if end_lno is not None else None
119119
self.end_offset = exc_value.end_offset
120+
elif (
121+
exc_type
122+
and issubclass(exc_type, (NameError, AttributeError))
123+
and getattr(exc_value, "name", None) is not None
124+
):
125+
suggestion = _compute_suggestion_error(exc_value, exc_traceback)
126+
if suggestion:
127+
self._str += f". Did you mean: '{suggestion}'?"
120128

121129
if lookup_lines:
122130
# Force all lines in the stack to be loaded
@@ -416,3 +424,127 @@ def print_exc(
416424
) -> None:
417425
value = sys.exc_info()[1]
418426
print_exception(value, limit, file, chain)
427+
428+
429+
# Python levenshtein edit distance code for NameError/AttributeError
430+
# suggestions, backported from 3.12
431+
432+
_MAX_CANDIDATE_ITEMS = 750
433+
_MAX_STRING_SIZE = 40
434+
_MOVE_COST = 2
435+
_CASE_COST = 1
436+
437+
438+
def _substitution_cost(ch_a, ch_b):
439+
if ch_a == ch_b:
440+
return 0
441+
if ch_a.lower() == ch_b.lower():
442+
return _CASE_COST
443+
return _MOVE_COST
444+
445+
446+
def _compute_suggestion_error(exc_value, tb):
447+
wrong_name = getattr(exc_value, "name", None)
448+
if wrong_name is None or not isinstance(wrong_name, str):
449+
return None
450+
if isinstance(exc_value, AttributeError):
451+
obj = exc_value.obj
452+
try:
453+
d = dir(obj)
454+
except Exception:
455+
return None
456+
else:
457+
assert isinstance(exc_value, NameError)
458+
# find most recent frame
459+
if tb is None:
460+
return None
461+
while tb.tb_next is not None:
462+
tb = tb.tb_next
463+
frame = tb.tb_frame
464+
465+
d = list(frame.f_locals) + list(frame.f_globals) + list(frame.f_builtins)
466+
if len(d) > _MAX_CANDIDATE_ITEMS:
467+
return None
468+
wrong_name_len = len(wrong_name)
469+
if wrong_name_len > _MAX_STRING_SIZE:
470+
return None
471+
best_distance = wrong_name_len
472+
suggestion = None
473+
for possible_name in d:
474+
if possible_name == wrong_name:
475+
# A missing attribute is "found". Don't suggest it (see GH-88821).
476+
continue
477+
# No more than 1/3 of the involved characters should need changed.
478+
max_distance = (len(possible_name) + wrong_name_len + 3) * _MOVE_COST // 6
479+
# Don't take matches we've already beaten.
480+
max_distance = min(max_distance, best_distance - 1)
481+
current_distance = _levenshtein_distance(
482+
wrong_name, possible_name, max_distance
483+
)
484+
if current_distance > max_distance:
485+
continue
486+
if not suggestion or current_distance < best_distance:
487+
suggestion = possible_name
488+
best_distance = current_distance
489+
return suggestion
490+
491+
492+
def _levenshtein_distance(a, b, max_cost):
493+
# A Python implementation of Python/suggestions.c:levenshtein_distance.
494+
495+
# Both strings are the same
496+
if a == b:
497+
return 0
498+
499+
# Trim away common affixes
500+
pre = 0
501+
while a[pre:] and b[pre:] and a[pre] == b[pre]:
502+
pre += 1
503+
a = a[pre:]
504+
b = b[pre:]
505+
post = 0
506+
while a[: post or None] and b[: post or None] and a[post - 1] == b[post - 1]:
507+
post -= 1
508+
a = a[: post or None]
509+
b = b[: post or None]
510+
if not a or not b:
511+
return _MOVE_COST * (len(a) + len(b))
512+
if len(a) > _MAX_STRING_SIZE or len(b) > _MAX_STRING_SIZE:
513+
return max_cost + 1
514+
515+
# Prefer shorter buffer
516+
if len(b) < len(a):
517+
a, b = b, a
518+
519+
# Quick fail when a match is impossible
520+
if (len(b) - len(a)) * _MOVE_COST > max_cost:
521+
return max_cost + 1
522+
523+
# Instead of producing the whole traditional len(a)-by-len(b)
524+
# matrix, we can update just one row in place.
525+
# Initialize the buffer row
526+
row = list(range(_MOVE_COST, _MOVE_COST * (len(a) + 1), _MOVE_COST))
527+
528+
result = 0
529+
for bindex in range(len(b)):
530+
bchar = b[bindex]
531+
distance = result = bindex * _MOVE_COST
532+
minimum = sys.maxsize
533+
for index in range(len(a)):
534+
# 1) Previous distance in this row is cost(b[:b_index], a[:index])
535+
substitute = distance + _substitution_cost(bchar, a[index])
536+
# 2) cost(b[:b_index], a[:index+1]) from previous row
537+
distance = row[index]
538+
# 3) existing result is cost(b[:b_index+1], a[index])
539+
540+
insert_delete = min(result, distance) + _MOVE_COST
541+
result = min(insert_delete, substitute)
542+
543+
# cost(b[:b_index+1], a[:index+1])
544+
row[index] = result
545+
if result < minimum:
546+
minimum = result
547+
if minimum > max_cost:
548+
# Everything in this row is too big, so bail early.
549+
return max_cost + 1
550+
return result

tests/test_formatting.py

+49
Original file line numberDiff line numberDiff line change
@@ -455,3 +455,52 @@ def test_print_exc(
455455
+------------------------------------
456456
"""
457457
)
458+
459+
460+
@pytest.mark.skipif(
461+
not hasattr(NameError, "name") or sys.version_info[:2] == (3, 11),
462+
reason="only works if NameError exposes the missing name",
463+
)
464+
def test_nameerror_suggestions(
465+
patched: bool, monkeypatch: MonkeyPatch, capsys: CaptureFixture
466+
) -> None:
467+
if not patched:
468+
# Block monkey patching, then force the module to be re-imported
469+
del sys.modules["traceback"]
470+
del sys.modules["exceptiongroup"]
471+
del sys.modules["exceptiongroup._formatting"]
472+
monkeypatch.setattr(sys, "excepthook", lambda *args: sys.__excepthook__(*args))
473+
474+
from exceptiongroup import print_exc
475+
476+
try:
477+
folder
478+
except NameError:
479+
print_exc()
480+
output = capsys.readouterr().err
481+
assert "Did you mean" in output and "'filter'?" in output
482+
483+
484+
@pytest.mark.skipif(
485+
not hasattr(AttributeError, "name") or sys.version_info[:2] == (3, 11),
486+
reason="only works if AttributeError exposes the missing name",
487+
)
488+
def test_nameerror_suggestions_in_group(
489+
patched: bool, monkeypatch: MonkeyPatch, capsys: CaptureFixture
490+
) -> None:
491+
if not patched:
492+
# Block monkey patching, then force the module to be re-imported
493+
del sys.modules["traceback"]
494+
del sys.modules["exceptiongroup"]
495+
del sys.modules["exceptiongroup._formatting"]
496+
monkeypatch.setattr(sys, "excepthook", lambda *args: sys.__excepthook__(*args))
497+
498+
from exceptiongroup import print_exception
499+
500+
try:
501+
[].attend
502+
except AttributeError as e:
503+
eg = ExceptionGroup("a", [e])
504+
print_exception(eg)
505+
output = capsys.readouterr().err
506+
assert "Did you mean" in output and "'append'?" in output

0 commit comments

Comments
 (0)