Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

issue #41: backport the pure python suggestion code from 3.12 #42

Merged
merged 6 commits into from
Nov 14, 2022
Merged
Show file tree
Hide file tree
Changes from all 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
132 changes: 132 additions & 0 deletions src/exceptiongroup/_formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,14 @@ def __init__(
end_lno = exc_value.end_lineno
self.end_lineno = str(end_lno) if end_lno is not None else None
self.end_offset = exc_value.end_offset
elif (
exc_type
and issubclass(exc_type, (NameError, AttributeError))
and getattr(exc_value, "name", None) is not None
):
suggestion = _compute_suggestion_error(exc_value, exc_traceback)
if suggestion:
self._str += f". Did you mean: '{suggestion}'?"

if lookup_lines:
# Force all lines in the stack to be loaded
Expand Down Expand Up @@ -416,3 +424,127 @@ def print_exc(
) -> None:
value = sys.exc_info()[1]
print_exception(value, limit, file, chain)


# Python levenshtein edit distance code for NameError/AttributeError
# suggestions, backported from 3.12

_MAX_CANDIDATE_ITEMS = 750
_MAX_STRING_SIZE = 40
_MOVE_COST = 2
_CASE_COST = 1


def _substitution_cost(ch_a, ch_b):
if ch_a == ch_b:
return 0
if ch_a.lower() == ch_b.lower():
return _CASE_COST
return _MOVE_COST


def _compute_suggestion_error(exc_value, tb):
wrong_name = getattr(exc_value, "name", None)
if wrong_name is None or not isinstance(wrong_name, str):
return None
if isinstance(exc_value, AttributeError):
obj = exc_value.obj
try:
d = dir(obj)
except Exception:
return None
else:
assert isinstance(exc_value, NameError)
# find most recent frame
if tb is None:
return None
while tb.tb_next is not None:
tb = tb.tb_next
frame = tb.tb_frame

d = list(frame.f_locals) + list(frame.f_globals) + list(frame.f_builtins)
if len(d) > _MAX_CANDIDATE_ITEMS:
return None
wrong_name_len = len(wrong_name)
if wrong_name_len > _MAX_STRING_SIZE:
return None
best_distance = wrong_name_len
suggestion = None
for possible_name in d:
if possible_name == wrong_name:
# A missing attribute is "found". Don't suggest it (see GH-88821).
continue
# No more than 1/3 of the involved characters should need changed.
max_distance = (len(possible_name) + wrong_name_len + 3) * _MOVE_COST // 6
# Don't take matches we've already beaten.
max_distance = min(max_distance, best_distance - 1)
current_distance = _levenshtein_distance(
wrong_name, possible_name, max_distance
)
if current_distance > max_distance:
continue
if not suggestion or current_distance < best_distance:
suggestion = possible_name
best_distance = current_distance
return suggestion


def _levenshtein_distance(a, b, max_cost):
# A Python implementation of Python/suggestions.c:levenshtein_distance.

# Both strings are the same
if a == b:
return 0

# Trim away common affixes
pre = 0
while a[pre:] and b[pre:] and a[pre] == b[pre]:
pre += 1
a = a[pre:]
b = b[pre:]
post = 0
while a[: post or None] and b[: post or None] and a[post - 1] == b[post - 1]:
post -= 1
a = a[: post or None]
b = b[: post or None]
if not a or not b:
return _MOVE_COST * (len(a) + len(b))
if len(a) > _MAX_STRING_SIZE or len(b) > _MAX_STRING_SIZE:
return max_cost + 1

# Prefer shorter buffer
if len(b) < len(a):
a, b = b, a

# Quick fail when a match is impossible
if (len(b) - len(a)) * _MOVE_COST > max_cost:
return max_cost + 1

# Instead of producing the whole traditional len(a)-by-len(b)
# matrix, we can update just one row in place.
# Initialize the buffer row
row = list(range(_MOVE_COST, _MOVE_COST * (len(a) + 1), _MOVE_COST))

result = 0
for bindex in range(len(b)):
bchar = b[bindex]
distance = result = bindex * _MOVE_COST
minimum = sys.maxsize
for index in range(len(a)):
# 1) Previous distance in this row is cost(b[:b_index], a[:index])
substitute = distance + _substitution_cost(bchar, a[index])
# 2) cost(b[:b_index], a[:index+1]) from previous row
distance = row[index]
# 3) existing result is cost(b[:b_index+1], a[index])

insert_delete = min(result, distance) + _MOVE_COST
result = min(insert_delete, substitute)

# cost(b[:b_index+1], a[:index+1])
row[index] = result
if result < minimum:
minimum = result
if minimum > max_cost:
# Everything in this row is too big, so bail early.
return max_cost + 1
return result
49 changes: 49 additions & 0 deletions tests/test_formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,3 +455,52 @@ def test_print_exc(
+------------------------------------
"""
)


@pytest.mark.skipif(
not hasattr(NameError, "name") or sys.version_info[:2] == (3, 11),
reason="only works if NameError exposes the missing name",
)
def test_nameerror_suggestions(
patched: bool, monkeypatch: MonkeyPatch, capsys: CaptureFixture
) -> None:
if not patched:
# Block monkey patching, then force the module to be re-imported
del sys.modules["traceback"]
del sys.modules["exceptiongroup"]
del sys.modules["exceptiongroup._formatting"]
monkeypatch.setattr(sys, "excepthook", lambda *args: sys.__excepthook__(*args))

from exceptiongroup import print_exc

try:
folder
except NameError:
print_exc()
output = capsys.readouterr().err
assert "Did you mean" in output and "'filter'?" in output


@pytest.mark.skipif(
not hasattr(AttributeError, "name") or sys.version_info[:2] == (3, 11),
reason="only works if AttributeError exposes the missing name",
)
def test_nameerror_suggestions_in_group(
patched: bool, monkeypatch: MonkeyPatch, capsys: CaptureFixture
) -> None:
if not patched:
# Block monkey patching, then force the module to be re-imported
del sys.modules["traceback"]
del sys.modules["exceptiongroup"]
del sys.modules["exceptiongroup._formatting"]
monkeypatch.setattr(sys, "excepthook", lambda *args: sys.__excepthook__(*args))

from exceptiongroup import print_exception

try:
[].attend
except AttributeError as e:
eg = ExceptionGroup("a", [e])
print_exception(eg)
output = capsys.readouterr().err
assert "Did you mean" in output and "'append'?" in output