diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 681508eedb6fbb1b044df8d813203976720adc76..e3d5a638131c90ef70213c0191c34f0175c2b784 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -2,7 +2,7 @@ name: Tests on: [push, pull_request] jobs: build: - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 strategy: matrix: python-version: [3.6, 3.7, 3.8, 3.9, '3.10', 3.11-dev] diff --git a/make_release.sh b/make_release.sh index f0c1ac84a64f29c6b0af412ba2f0f4bc7d55a9e0..0415e1d35e3066592514d0866719686e4adeab7c 100755 --- a/make_release.sh +++ b/make_release.sh @@ -20,7 +20,7 @@ else fi fi -tox -p auto +tox -p 3 export TAG="v${1}" git tag "${TAG}" diff --git a/setup.cfg b/setup.cfg index dc8b29ba3a133b5f0fccae8b5fea81e02e347961..05f3ddac28c780df1ae323fe2e072d76c409e77a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -22,7 +22,11 @@ classifiers = [options] packages = stack_data -install_requires = executing; asttokens; pure_eval +install_requires = + executing>=1.2.0 + asttokens>=2.1.0 + pure_eval + setup_requires = setuptools>=44; setuptools_scm[toml]>=3.4.3 include_package_data = True tests_require = pytest; typeguard; pygments; littleutils diff --git a/stack_data/core.py b/stack_data/core.py index e2ca2d5a246613fe6d4cc050b6b14000d0d682ce..88e060392adc5d713b5593302cbfb01023a37613 100644 --- a/stack_data/core.py +++ b/stack_data/core.py @@ -91,11 +91,6 @@ class Source(executing.Source): Don't construct this class. Get an instance from frame_info.source. """ - def __init__(self, *args, **kwargs): - super(Source, self).__init__(*args, **kwargs) - if self.tree: - self.asttokens() - @cached_property def pieces(self) -> List[range]: if not self.tree: @@ -122,6 +117,20 @@ class Source(executing.Source): if end > start ] + # Combine overlapping pieces, i.e. consecutive pieces where the end of the first + # is greater than the start of the second. + # This can happen when two statements are on the same line separated by a semicolon. + new_pieces = pieces[:1] + for (start, end) in pieces[1:]: + (last_start, last_end) = new_pieces[-1] + if start < last_end: + assert start == last_end - 1 + assert ';' in self.lines[start - 1] + new_pieces[-1] = (last_start, end) + else: + new_pieces.append((start, end)) + pieces = new_pieces + starts = [start for start, end in pieces[1:]] ends = [end for start, end in pieces[:-1]] if starts != ends: @@ -149,14 +158,12 @@ class Source(executing.Source): start: int, end: int, ) -> Iterator[Tuple[int, int]]: - self.asttokens() - for name, body in ast.iter_fields(stmt): if ( isinstance(body, list) and body and isinstance(body[0], (ast.stmt, ast.ExceptHandler, getattr(ast, 'match_case', ()))) ): - for rang, group in sorted(group_by_key_func(body, line_range).items()): + for rang, group in sorted(group_by_key_func(body, self.line_range).items()): sub_stmt = group[0] for inner_start, inner_end in self._raw_split_into_pieces(sub_stmt, *rang): if start < inner_start: @@ -167,6 +174,9 @@ class Source(executing.Source): yield start, end + def line_range(self, node: ast.AST) -> Tuple[int, int]: + return line_range(self.asttext(), node) + class Options: """ @@ -342,28 +352,18 @@ class Line(object): with the correct start and end and the given data. Otherwise, return None. """ - start, end = line_range(node) - end -= 1 + atext = self.frame_info.source.asttext() + (start, range_start), (end, range_end) = atext.get_text_positions(node, padded=False) + if not (start <= self.lineno <= end): return None - if start == self.lineno: - try: - range_start = node.first_token.start[1] - except AttributeError: - range_start = node.col_offset - else: + + if start != self.lineno: range_start = common_indent - if end == self.lineno: - try: - range_end = node.last_token.end[1] - except AttributeError: - try: - range_end = node.end_col_offset - except AttributeError: - return None - else: + if end != self.lineno: range_end = len(self.text) + if range_start == range_end == 0: # This is an empty line. If it were included, it would result # in a value of zero for the common indentation assigned to @@ -611,7 +611,7 @@ class FrameInfo(object): if not self.scope: return self.source.pieces - scope_start, scope_end = line_range(self.scope) + scope_start, scope_end = self.source.line_range(self.scope) return [ piece for piece in self.source.pieces @@ -809,21 +809,21 @@ class FrameInfo(object): if isinstance(formatter, HtmlFormatter): formatter.nowrap = True - atok = self.source.asttokens() + atext = self.source.asttext() node = self.executing.node if node and getattr(formatter.style, "for_executing_node", False): - scope_start = atok.get_text_range(scope)[0] - start, end = atok.get_text_range(node) + scope_start = atext.get_text_range(scope)[0] + start, end = atext.get_text_range(node) start -= scope_start end -= scope_start ranges = [(start, end)] else: ranges = [] - code = atok.get_text(scope) + code = atext.get_text(scope) lines = _pygmented_with_ranges(formatter, code, ranges) - start_line = line_range(scope)[0] + start_line = self.source.line_range(scope)[0] return start_line, lines @@ -861,7 +861,7 @@ class FrameInfo(object): if isinstance(n, ast.arg): return n.arg else: - return self.source.asttokens().get_text(n) + return self.source.asttext().get_text(n) def normalise_node(n): try: @@ -897,7 +897,7 @@ class FrameInfo(object): result = defaultdict(list) for var in self.variables: for node in var.nodes: - for lineno in range(*line_range(node)): + for lineno in range(*self.source.line_range(node)): result[lineno].append((var, node)) return result diff --git a/stack_data/utils.py b/stack_data/utils.py index 9cd0633c56d3a47171266487386700ad68a0fba9..78ce2d60a400b0acb3753ca038412ed1b66d0199 100644 --- a/stack_data/utils.py +++ b/stack_data/utils.py @@ -8,6 +8,8 @@ from typing import ( TypeVar, Mapping, ) +from asttokens import ASTText + T = TypeVar('T') R = TypeVar('R') @@ -24,22 +26,19 @@ def unique_in_order(it: Iterable[T]) -> List[T]: return list(OrderedDict.fromkeys(it)) -def line_range(node: ast.AST) -> Tuple[int, int]: +def line_range(atok: ASTText, node: ast.AST) -> Tuple[int, int]: """ Returns a pair of numbers representing a half open range (i.e. suitable as arguments to the `range()` builtin) of line numbers of the given AST nodes. """ - try: - return ( - node.first_token.start[0], - node.last_token.end[0] + 1, - ) - except AttributeError: - return ( - node.lineno, - getattr(node, "end_lineno", node.lineno) + 1, - ) + if isinstance(node, getattr(ast, "match_case", ())): + start, _end = line_range(atok, node.pattern) + _start, end = line_range(atok, node.body[-1]) + return start, end + else: + (start, _), (end, _) = atok.get_text_positions(node, padded=False) + return start, end + 1 def highlight_unique(lst: List[T]) -> Iterator[Tuple[T, bool]]: @@ -162,7 +161,12 @@ def _pygmented_with_ranges(formatter, code, ranges): yield ttype, value lexer = MyLexer(stripnl=False) - return pygments.highlight(code, lexer, formatter).splitlines() + try: + highlighted = pygments.highlight(code, lexer, formatter) + except Exception: + # When pygments fails, prefer code without highlighting over crashing + highlighted = code + return highlighted.splitlines() def assert_(condition, error=""): diff --git a/tests/golden_files/f_string_3.8.txt b/tests/golden_files/f_string_3.8.txt new file mode 100644 index 0000000000000000000000000000000000000000..e9d447a74d284a0f07b62d1ab1ff7e214d7e8d7a --- /dev/null +++ b/tests/golden_files/f_string_3.8.txt @@ -0,0 +1,11 @@ +Traceback (most recent call last): + File "formatter_example.py", line 57, in f_string + 54 | def f_string(): + 55 | f"""{str + 56 | ( +--> 57 | 1 / + 58 | 0 + 4 + 59 | + 5 + 60 | ) + 61 | }""" +ZeroDivisionError: division by zero diff --git a/tests/golden_files/pygmented_error.txt b/tests/golden_files/pygmented_error.txt new file mode 100644 index 0000000000000000000000000000000000000000..3971566b26fcadfd5909f14e2e3f0c7a0368db57 --- /dev/null +++ b/tests/golden_files/pygmented_error.txt @@ -0,0 +1,54 @@ +Traceback (most recent call last): + File "formatter_example.py", line 21, in foo + 9 | x = 1 + 10 | lst = ( + 11 | [ + 12 | x, +(...) + 18 | + [] + 19 | ) + 20 | try: +--> 21 | return int(str(lst)) + 22 | except: +ValueError: invalid literal for int() with base 10: '[1]' + +During handling of the above exception, another exception occurred: + +Traceback (most recent call last): + File "formatter_example.py", line 24, in foo + 21 | return int(str(lst)) + 22 | except: + 23 | try: +--> 24 | return 1 / 0 + 25 | except Exception as e: +ZeroDivisionError: division by zero + +The above exception was the direct cause of the following exception: + +Traceback (most recent call last): + File "formatter_example.py", line 30, in bar + 29 | def bar(): +--> 30 | exec("foo()") + File "<string>", line 1, in <module> + File "formatter_example.py", line 8, in foo + 6 | def foo(n=5): + 7 | if n > 0: +--> 8 | return foo(n - 1) + 9 | x = 1 + File "formatter_example.py", line 8, in foo + 6 | def foo(n=5): + 7 | if n > 0: +--> 8 | return foo(n - 1) + 9 | x = 1 + [... skipping similar frames: foo at line 8 (2 times)] + File "formatter_example.py", line 8, in foo + 6 | def foo(n=5): + 7 | if n > 0: +--> 8 | return foo(n - 1) + 9 | x = 1 + File "formatter_example.py", line 26, in foo + 23 | try: + 24 | return 1 / 0 + 25 | except Exception as e: +--> 26 | raise TypeError from e +TypeError diff --git a/tests/samples/pieces.py b/tests/samples/pieces.py index d61b899ecb9892742f33e02fc1c1fa265a19e0ea..cc34a36a41cc91a569f42cef0fcb942a7f3e4e1e 100644 --- a/tests/samples/pieces.py +++ b/tests/samples/pieces.py @@ -79,3 +79,19 @@ class Foo(object): @property def foo(self): return 3 + + +# noinspection PyTrailingSemicolon +def semicolons(): + if 1: + print(1, + 2); print(3, + 4); print(5, + 6) + if 2: + print(1, + 2); print(3, 4); print(5, + 6) + print(1, 2); print(3, + 4); print(5, 6) + print(1, 2);print(3, 4);print(5, 6) diff --git a/tests/test_core.py b/tests/test_core.py index 870497dda487db9d2ea40c9a734acd0dc0ee9ea7..52ad3aeac1aa8f1a33cc4565d49e9b6b39984404 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -399,7 +399,21 @@ def test_pieces(): ['class Foo(object):'], [' @property', ' def foo(self):'], - [' return 3'] + [' return 3'], + ['# noinspection PyTrailingSemicolon'], + ['def semicolons():'], + [' if 1:'], + [' print(1,', + ' 2); print(3,', + ' 4); print(5,', + ' 6)'], + [' if 2:'], + [' print(1,', + ' 2); print(3, 4); print(5,', + ' 6)'], + [' print(1, 2); print(3,', + ' 4); print(5, 6)'], + [' print(1, 2);print(3, 4);print(5, 6)'] ] @@ -501,7 +515,7 @@ def check_pieces(source): assert pieces == sorted(pieces, key=lambda p: (p.start, p.stop)) stmts = sorted({ - line_range(node) + source.line_range(node) for node in ast.walk(source.tree) if isinstance(node, ast.stmt) if not isinstance(getattr(node, 'body', None), list) diff --git a/tests/test_formatter.py b/tests/test_formatter.py index 1e40b68008ba3b017d3c16d3d1ad3ce005cb4516..31862f82e44ac409d3f06be641e897c462a34a5a 100644 --- a/tests/test_formatter.py +++ b/tests/test_formatter.py @@ -4,6 +4,7 @@ import sys from contextlib import contextmanager import pytest +import pygments from stack_data import Formatter, FrameInfo, Options, BlankLines from tests.utils import compare_to_file @@ -56,6 +57,16 @@ def test_example(capsys): except Exception: sys.excepthook(*sys.exc_info()) + with check_example("pygmented_error"): + h = pygments.highlight + pygments.highlight = lambda *args, **kwargs: 1/0 + try: + bar() + except Exception: + MyFormatter(pygmented=True).print_exception() + finally: + pygments.highlight = h + with check_example("print_stack"): print_stack1(MyFormatter()) @@ -69,7 +80,15 @@ def test_example(capsys): formatted = format_frame(formatter) formatter.print_lines(formatted) - with check_example(f"f_string_{'old' if sys.version_info[:2] < (3, 8) else 'new'}"): + if sys.version_info[:2] < (3, 8): + f_string_suffix = 'old' + elif sys.version_info[:2] == (3, 8): + # lineno/col_offset in f-strings cannot be trusted in 3.8 + f_string_suffix = '3.8' + else: + f_string_suffix = 'new' + + with check_example(f"f_string_{f_string_suffix}"): try: f_string() except Exception: