|
import ast |
|
import html |
|
import os |
|
import sys |
|
from collections import defaultdict, Counter |
|
from enum import Enum |
|
from textwrap import dedent |
|
from types import FrameType, CodeType, TracebackType |
|
from typing import ( |
|
Iterator, List, Tuple, Optional, NamedTuple, |
|
Any, Iterable, Callable, Union, |
|
Sequence) |
|
from typing import Mapping |
|
|
|
import executing |
|
from asttokens.util import Token |
|
from executing import only |
|
from pure_eval import Evaluator, is_expression_interesting |
|
from stack_data.utils import ( |
|
truncate, unique_in_order, line_range, |
|
frame_and_lineno, iter_stack, collapse_repeated, group_by_key_func, |
|
cached_property, is_frame, _pygmented_with_ranges, assert_) |
|
|
|
RangeInLine = NamedTuple('RangeInLine', |
|
[('start', int), |
|
('end', int), |
|
('data', Any)]) |
|
RangeInLine.__doc__ = """ |
|
Represents a range of characters within one line of source code, |
|
and some associated data. |
|
|
|
Typically this will be converted to a pair of markers by markers_from_ranges. |
|
""" |
|
|
|
MarkerInLine = NamedTuple('MarkerInLine', |
|
[('position', int), |
|
('is_start', bool), |
|
('string', str)]) |
|
MarkerInLine.__doc__ = """ |
|
A string that is meant to be inserted at a given position in a line of source code. |
|
For example, this could be an ANSI code or the opening or closing of an HTML tag. |
|
is_start should be True if this is the first of a pair such as the opening of an HTML tag. |
|
This will help to sort and insert markers correctly. |
|
|
|
Typically this would be created from a RangeInLine by markers_from_ranges. |
|
Then use Line.render to insert the markers correctly. |
|
""" |
|
|
|
|
|
class BlankLines(Enum): |
|
"""The values are intended to correspond to the following behaviour: |
|
HIDDEN: blank lines are not shown in the output |
|
VISIBLE: blank lines are visible in the output |
|
SINGLE: any consecutive blank lines are shown as a single blank line |
|
in the output. This option requires the line number to be shown. |
|
For a single blank line, the corresponding line number is shown. |
|
Two or more consecutive blank lines are shown as a single blank |
|
line in the output with a custom string shown instead of a |
|
specific line number. |
|
""" |
|
HIDDEN = 1 |
|
VISIBLE = 2 |
|
SINGLE=3 |
|
|
|
class Variable( |
|
NamedTuple('_Variable', |
|
[('name', str), |
|
('nodes', Sequence[ast.AST]), |
|
('value', Any)]) |
|
): |
|
""" |
|
An expression that appears one or more times in source code and its associated value. |
|
This will usually be a variable but it can be any expression evaluated by pure_eval. |
|
- name is the source text of the expression. |
|
- nodes is a list of equivalent nodes representing the same expression. |
|
- value is the safely evaluated value of the expression. |
|
""" |
|
__hash__ = object.__hash__ |
|
__eq__ = object.__eq__ |
|
|
|
|
|
class Source(executing.Source): |
|
""" |
|
The source code of a single file and associated metadata. |
|
|
|
In addition to the attributes from the base class executing.Source, |
|
if .tree is not None, meaning this is valid Python code, objects have: |
|
- pieces: a list of Piece objects |
|
- tokens_by_lineno: a defaultdict(list) mapping line numbers to lists of tokens. |
|
|
|
Don't construct this class. Get an instance from frame_info.source. |
|
""" |
|
|
|
@cached_property |
|
def pieces(self) -> List[range]: |
|
if not self.tree: |
|
return [ |
|
range(i, i + 1) |
|
for i in range(1, len(self.lines) + 1) |
|
] |
|
return list(self._clean_pieces()) |
|
|
|
@cached_property |
|
def tokens_by_lineno(self) -> Mapping[int, List[Token]]: |
|
if not self.tree: |
|
raise AttributeError("This file doesn't contain valid Python, so .tokens_by_lineno doesn't exist") |
|
return group_by_key_func( |
|
self.asttokens().tokens, |
|
lambda tok: tok.start[0], |
|
) |
|
|
|
def _clean_pieces(self) -> Iterator[range]: |
|
pieces = self._raw_split_into_pieces(self.tree, 1, len(self.lines) + 1) |
|
pieces = [ |
|
(start, end) |
|
for (start, end) in pieces |
|
if end > start |
|
] |
|
|
|
|
|
|
|
|
|
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: |
|
joins = list(map(set, zip(starts, ends))) |
|
mismatches = [s for s in joins if len(s) > 1] |
|
raise AssertionError("Pieces mismatches: %s" % mismatches) |
|
|
|
def is_blank(i): |
|
try: |
|
return not self.lines[i - 1].strip() |
|
except IndexError: |
|
return False |
|
|
|
for start, end in pieces: |
|
while is_blank(start): |
|
start += 1 |
|
while is_blank(end - 1): |
|
end -= 1 |
|
if start < end: |
|
yield range(start, end) |
|
|
|
def _raw_split_into_pieces( |
|
self, |
|
stmt: ast.AST, |
|
start: int, |
|
end: int, |
|
) -> Iterator[Tuple[int, int]]: |
|
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, 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: |
|
yield start, inner_start |
|
if inner_start < inner_end: |
|
yield inner_start, inner_end |
|
start = inner_end |
|
|
|
yield start, end |
|
|
|
def line_range(self, node: ast.AST) -> Tuple[int, int]: |
|
return line_range(self.asttext(), node) |
|
|
|
|
|
class Options: |
|
""" |
|
Configuration for FrameInfo, either in the constructor or the .stack_data classmethod. |
|
These all determine which Lines and gaps are produced by FrameInfo.lines. |
|
|
|
before and after are the number of pieces of context to include in a frame |
|
in addition to the executing piece. |
|
|
|
include_signature is whether to include the function signature as a piece in a frame. |
|
|
|
If a piece (other than the executing piece) has more than max_lines_per_piece lines, |
|
it will be truncated with a gap in the middle. |
|
""" |
|
def __init__( |
|
self, *, |
|
before: int = 3, |
|
after: int = 1, |
|
include_signature: bool = False, |
|
max_lines_per_piece: int = 6, |
|
pygments_formatter=None, |
|
blank_lines = BlankLines.HIDDEN |
|
): |
|
self.before = before |
|
self.after = after |
|
self.include_signature = include_signature |
|
self.max_lines_per_piece = max_lines_per_piece |
|
self.pygments_formatter = pygments_formatter |
|
self.blank_lines = blank_lines |
|
|
|
def __repr__(self): |
|
keys = sorted(self.__dict__) |
|
items = ("{}={!r}".format(k, self.__dict__[k]) for k in keys) |
|
return "{}({})".format(type(self).__name__, ", ".join(items)) |
|
|
|
|
|
class LineGap(object): |
|
""" |
|
A singleton representing one or more lines of source code that were skipped |
|
in FrameInfo.lines. |
|
|
|
LINE_GAP can be created in two ways: |
|
- by truncating a piece of context that's too long. |
|
- immediately after the signature piece if Options.include_signature is true |
|
and the following piece isn't already part of the included pieces. |
|
""" |
|
def __repr__(self): |
|
return "LINE_GAP" |
|
|
|
|
|
LINE_GAP = LineGap() |
|
|
|
|
|
class BlankLineRange: |
|
""" |
|
Records the line number range for blank lines gaps between pieces. |
|
For a single blank line, begin_lineno == end_lineno. |
|
""" |
|
def __init__(self, begin_lineno: int, end_lineno: int): |
|
self.begin_lineno = begin_lineno |
|
self.end_lineno = end_lineno |
|
|
|
|
|
class Line(object): |
|
""" |
|
A single line of source code for a particular stack frame. |
|
|
|
Typically this is obtained from FrameInfo.lines. |
|
Since that list may also contain LINE_GAP, you should first check |
|
that this is really a Line before using it. |
|
|
|
Attributes: |
|
- frame_info |
|
- lineno: the 1-based line number within the file |
|
- text: the raw source of this line. For displaying text, see .render() instead. |
|
- leading_indent: the number of leading spaces that should probably be stripped. |
|
This attribute is set within FrameInfo.lines. If you construct this class |
|
directly you should probably set it manually (at least to 0). |
|
- is_current: whether this is the line currently being executed by the interpreter |
|
within this frame. |
|
- tokens: a list of source tokens in this line |
|
|
|
There are several helpers for constructing RangeInLines which can be converted to markers |
|
using markers_from_ranges which can be passed to .render(): |
|
- token_ranges |
|
- variable_ranges |
|
- executing_node_ranges |
|
- range_from_node |
|
""" |
|
def __init__( |
|
self, |
|
frame_info: 'FrameInfo', |
|
lineno: int, |
|
): |
|
self.frame_info = frame_info |
|
self.lineno = lineno |
|
self.text = frame_info.source.lines[lineno - 1] |
|
self.leading_indent = None |
|
|
|
def __repr__(self): |
|
return "<{self.__class__.__name__} {self.lineno} (current={self.is_current}) " \ |
|
"{self.text!r} of {self.frame_info.filename}>".format(self=self) |
|
|
|
@property |
|
def is_current(self) -> bool: |
|
""" |
|
Whether this is the line currently being executed by the interpreter |
|
within this frame. |
|
""" |
|
return self.lineno == self.frame_info.lineno |
|
|
|
@property |
|
def tokens(self) -> List[Token]: |
|
""" |
|
A list of source tokens in this line. |
|
The tokens are Token objects from asttokens: |
|
https://asttokens.readthedocs.io/en/latest/api-index.html#asttokens.util.Token |
|
""" |
|
return self.frame_info.source.tokens_by_lineno[self.lineno] |
|
|
|
@cached_property |
|
def token_ranges(self) -> List[RangeInLine]: |
|
""" |
|
A list of RangeInLines for each token in .tokens, |
|
where range.data is a Token object from asttokens: |
|
https://asttokens.readthedocs.io/en/latest/api-index.html#asttokens.util.Token |
|
""" |
|
return [ |
|
RangeInLine( |
|
token.start[1], |
|
token.end[1], |
|
token, |
|
) |
|
for token in self.tokens |
|
] |
|
|
|
@cached_property |
|
def variable_ranges(self) -> List[RangeInLine]: |
|
""" |
|
A list of RangeInLines for each Variable that appears at least partially in this line. |
|
The data attribute of the range is a pair (variable, node) where node is the particular |
|
AST node from the list variable.nodes that corresponds to this range. |
|
""" |
|
return [ |
|
self.range_from_node(node, (variable, node)) |
|
for variable, node in self.frame_info.variables_by_lineno[self.lineno] |
|
] |
|
|
|
@cached_property |
|
def executing_node_ranges(self) -> List[RangeInLine]: |
|
""" |
|
A list of one or zero RangeInLines for the executing node of this frame. |
|
The list will have one element if the node can be found and it overlaps this line. |
|
""" |
|
return self._raw_executing_node_ranges( |
|
self.frame_info._executing_node_common_indent |
|
) |
|
|
|
def _raw_executing_node_ranges(self, common_indent=0) -> List[RangeInLine]: |
|
ex = self.frame_info.executing |
|
node = ex.node |
|
if node: |
|
rang = self.range_from_node(node, ex, common_indent) |
|
if rang: |
|
return [rang] |
|
return [] |
|
|
|
def range_from_node( |
|
self, node: ast.AST, data: Any, common_indent: int = 0 |
|
) -> Optional[RangeInLine]: |
|
""" |
|
If the given node overlaps with this line, return a RangeInLine |
|
with the correct start and end and the given data. |
|
Otherwise, return None. |
|
""" |
|
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: |
|
range_start = common_indent |
|
|
|
if end != self.lineno: |
|
range_end = len(self.text) |
|
|
|
if range_start == range_end == 0: |
|
|
|
|
|
|
|
return None |
|
|
|
return RangeInLine(range_start, range_end, data) |
|
|
|
def render( |
|
self, |
|
markers: Iterable[MarkerInLine] = (), |
|
*, |
|
strip_leading_indent: bool = True, |
|
pygmented: bool = False, |
|
escape_html: bool = False |
|
) -> str: |
|
""" |
|
Produces a string for display consisting of .text |
|
with the .strings of each marker inserted at the correct positions. |
|
If strip_leading_indent is true (the default) then leading spaces |
|
common to all lines in this frame will be excluded. |
|
""" |
|
if pygmented and self.frame_info.scope: |
|
assert_(not markers, ValueError("Cannot use pygmented with markers")) |
|
start_line, lines = self.frame_info._pygmented_scope_lines |
|
result = lines[self.lineno - start_line] |
|
if strip_leading_indent: |
|
result = result.replace(self.text[:self.leading_indent], "", 1) |
|
return result |
|
|
|
text = self.text |
|
|
|
|
|
markers = list(markers) + [MarkerInLine(position=len(text), is_start=False, string='')] |
|
|
|
markers.sort(key=lambda t: t[:2]) |
|
|
|
parts = [] |
|
if strip_leading_indent: |
|
start = self.leading_indent |
|
else: |
|
start = 0 |
|
original_start = start |
|
|
|
for marker in markers: |
|
text_part = text[start:marker.position] |
|
if escape_html: |
|
text_part = html.escape(text_part) |
|
parts.append(text_part) |
|
parts.append(marker.string) |
|
|
|
|
|
start = max(marker.position, original_start) |
|
return ''.join(parts) |
|
|
|
|
|
def markers_from_ranges( |
|
ranges: Iterable[RangeInLine], |
|
converter: Callable[[RangeInLine], Optional[Tuple[str, str]]], |
|
) -> List[MarkerInLine]: |
|
""" |
|
Helper to create MarkerInLines given some RangeInLines. |
|
converter should be a function accepting a RangeInLine returning |
|
either None (which is ignored) or a pair of strings which |
|
are used to create two markers included in the returned list. |
|
""" |
|
markers = [] |
|
for rang in ranges: |
|
converted = converter(rang) |
|
if converted is None: |
|
continue |
|
|
|
start_string, end_string = converted |
|
if not (isinstance(start_string, str) and isinstance(end_string, str)): |
|
raise TypeError("converter should return None or a pair of strings") |
|
|
|
markers += [ |
|
MarkerInLine(position=rang.start, is_start=True, string=start_string), |
|
MarkerInLine(position=rang.end, is_start=False, string=end_string), |
|
] |
|
return markers |
|
|
|
|
|
def style_with_executing_node(style, modifier): |
|
from pygments.styles import get_style_by_name |
|
if isinstance(style, str): |
|
style = get_style_by_name(style) |
|
|
|
class NewStyle(style): |
|
for_executing_node = True |
|
|
|
styles = { |
|
**style.styles, |
|
**{ |
|
k.ExecutingNode: v + " " + modifier |
|
for k, v in style.styles.items() |
|
} |
|
} |
|
|
|
return NewStyle |
|
|
|
|
|
class RepeatedFrames: |
|
""" |
|
A sequence of consecutive stack frames which shouldn't be displayed because |
|
the same code and line number were repeated many times in the stack, e.g. |
|
because of deep recursion. |
|
|
|
Attributes: |
|
- frames: list of raw frame or traceback objects |
|
- frame_keys: list of tuples (frame.f_code, lineno) extracted from the frame objects. |
|
It's this information from the frames that is used to determine |
|
whether two frames should be considered similar (i.e. repeating). |
|
- description: A string briefly describing frame_keys |
|
""" |
|
def __init__( |
|
self, |
|
frames: List[Union[FrameType, TracebackType]], |
|
frame_keys: List[Tuple[CodeType, int]], |
|
): |
|
self.frames = frames |
|
self.frame_keys = frame_keys |
|
|
|
@cached_property |
|
def description(self) -> str: |
|
""" |
|
A string briefly describing the repeated frames, e.g. |
|
my_function at line 10 (100 times) |
|
""" |
|
counts = sorted(Counter(self.frame_keys).items(), |
|
key=lambda item: (-item[1], item[0][0].co_name)) |
|
return ', '.join( |
|
'{name} at line {lineno} ({count} times)'.format( |
|
name=Source.for_filename(code.co_filename).code_qualname(code), |
|
lineno=lineno, |
|
count=count, |
|
) |
|
for (code, lineno), count in counts |
|
) |
|
|
|
def __repr__(self): |
|
return '<{self.__class__.__name__} {self.description}>'.format(self=self) |
|
|
|
|
|
class FrameInfo(object): |
|
""" |
|
Information about a frame! |
|
Pass either a frame object or a traceback object, |
|
and optionally an Options object to configure. |
|
|
|
Or use the classmethod FrameInfo.stack_data() for an iterator of FrameInfo and |
|
RepeatedFrames objects. |
|
|
|
Attributes: |
|
- frame: an actual stack frame object, either frame_or_tb or frame_or_tb.tb_frame |
|
- options |
|
- code: frame.f_code |
|
- source: a Source object |
|
- filename: a hopefully absolute file path derived from code.co_filename |
|
- scope: the AST node of the innermost function, class or module being executed |
|
- lines: a list of Line/LineGap objects to display, determined by options |
|
- executing: an Executing object from the `executing` library, which has: |
|
- .node: the AST node being executed in this frame, or None if it's unknown |
|
- .statements: a set of one or more candidate statements (AST nodes, probably just one) |
|
currently being executed in this frame. |
|
- .code_qualname(): the __qualname__ of the function or class being executed, |
|
or just the code name. |
|
|
|
Properties returning one or more pieces of source code (ranges of lines): |
|
- scope_pieces: all the pieces in the scope |
|
- included_pieces: a subset of scope_pieces determined by options |
|
- executing_piece: the piece currently being executed in this frame |
|
|
|
Properties returning lists of Variable objects: |
|
- variables: all variables in the scope |
|
- variables_by_lineno: variables organised into lines |
|
- variables_in_lines: variables contained within FrameInfo.lines |
|
- variables_in_executing_piece: variables contained within FrameInfo.executing_piece |
|
""" |
|
def __init__( |
|
self, |
|
frame_or_tb: Union[FrameType, TracebackType], |
|
options: Optional[Options] = None, |
|
): |
|
self.executing = Source.executing(frame_or_tb) |
|
frame, self.lineno = frame_and_lineno(frame_or_tb) |
|
self.frame = frame |
|
self.code = frame.f_code |
|
self.options = options or Options() |
|
self.source = self.executing.source |
|
|
|
|
|
def __repr__(self): |
|
return "{self.__class__.__name__}({self.frame})".format(self=self) |
|
|
|
@classmethod |
|
def stack_data( |
|
cls, |
|
frame_or_tb: Union[FrameType, TracebackType], |
|
options: Optional[Options] = None, |
|
*, |
|
collapse_repeated_frames: bool = True |
|
) -> Iterator[Union['FrameInfo', RepeatedFrames]]: |
|
""" |
|
An iterator of FrameInfo and RepeatedFrames objects representing |
|
a full traceback or stack. Similar consecutive frames are collapsed into RepeatedFrames |
|
objects, so always check what type of object has been yielded. |
|
|
|
Pass either a frame object or a traceback object, |
|
and optionally an Options object to configure. |
|
""" |
|
stack = list(iter_stack(frame_or_tb)) |
|
|
|
|
|
|
|
|
|
if is_frame(frame_or_tb): |
|
stack = stack[::-1] |
|
|
|
def mapper(f): |
|
return cls(f, options) |
|
|
|
if not collapse_repeated_frames: |
|
yield from map(mapper, stack) |
|
return |
|
|
|
def _frame_key(x): |
|
frame, lineno = frame_and_lineno(x) |
|
return frame.f_code, lineno |
|
|
|
yield from collapse_repeated( |
|
stack, |
|
mapper=mapper, |
|
collapser=RepeatedFrames, |
|
key=_frame_key, |
|
) |
|
|
|
@cached_property |
|
def scope_pieces(self) -> List[range]: |
|
""" |
|
All the pieces (ranges of lines) contained in this object's .scope, |
|
unless there is no .scope (because the source isn't valid Python syntax) |
|
in which case it returns all the pieces in the source file, each containing one line. |
|
""" |
|
if not self.scope: |
|
return self.source.pieces |
|
|
|
scope_start, scope_end = self.source.line_range(self.scope) |
|
return [ |
|
piece |
|
for piece in self.source.pieces |
|
if scope_start <= piece.start and piece.stop <= scope_end |
|
] |
|
|
|
@cached_property |
|
def filename(self) -> str: |
|
""" |
|
A hopefully absolute file path derived from .code.co_filename, |
|
the current working directory, and sys.path. |
|
Code based on ipython. |
|
""" |
|
result = self.code.co_filename |
|
|
|
if ( |
|
os.path.isabs(result) or |
|
( |
|
result.startswith("<") and |
|
result.endswith(">") |
|
) |
|
): |
|
return result |
|
|
|
|
|
|
|
|
|
for dirname in ["."] + list(sys.path): |
|
try: |
|
fullname = os.path.join(dirname, result) |
|
if os.path.isfile(fullname): |
|
return os.path.abspath(fullname) |
|
except Exception: |
|
|
|
|
|
pass |
|
|
|
return result |
|
|
|
@cached_property |
|
def executing_piece(self) -> range: |
|
""" |
|
The piece (range of lines) containing the line currently being executed |
|
by the interpreter in this frame. |
|
""" |
|
return only( |
|
piece |
|
for piece in self.scope_pieces |
|
if self.lineno in piece |
|
) |
|
|
|
@cached_property |
|
def included_pieces(self) -> List[range]: |
|
""" |
|
The list of pieces (ranges of lines) to display for this frame. |
|
Consists of .executing_piece, surrounding context pieces |
|
determined by .options.before and .options.after, |
|
and the function signature if a function is being executed and |
|
.options.include_signature is True (in which case this might not |
|
be a contiguous range of pieces). |
|
Always a subset of .scope_pieces. |
|
""" |
|
scope_pieces = self.scope_pieces |
|
if not self.scope_pieces: |
|
return [] |
|
|
|
pos = scope_pieces.index(self.executing_piece) |
|
pieces_start = max(0, pos - self.options.before) |
|
pieces_end = pos + 1 + self.options.after |
|
pieces = scope_pieces[pieces_start:pieces_end] |
|
|
|
if ( |
|
self.options.include_signature |
|
and not self.code.co_name.startswith('<') |
|
and isinstance(self.scope, (ast.FunctionDef, ast.AsyncFunctionDef)) |
|
and pieces_start > 0 |
|
): |
|
pieces.insert(0, scope_pieces[0]) |
|
|
|
return pieces |
|
|
|
@cached_property |
|
def _executing_node_common_indent(self) -> int: |
|
""" |
|
The common minimal indentation shared by the markers intended |
|
for an exception node that spans multiple lines. |
|
|
|
Intended to be used only internally. |
|
""" |
|
indents = [] |
|
lines = [line for line in self.lines if isinstance(line, Line)] |
|
|
|
for line in lines: |
|
for rang in line._raw_executing_node_ranges(): |
|
begin_text = len(line.text) - len(line.text.lstrip()) |
|
indent = max(rang.start, begin_text) |
|
indents.append(indent) |
|
|
|
if len(indents) <= 1: |
|
return 0 |
|
|
|
return min(indents[1:]) |
|
|
|
@cached_property |
|
def lines(self) -> List[Union[Line, LineGap, BlankLineRange]]: |
|
""" |
|
A list of lines to display, determined by options. |
|
The objects yielded either have type Line, BlankLineRange |
|
or are the singleton LINE_GAP. |
|
Always check the type that you're dealing with when iterating. |
|
|
|
LINE_GAP can be created in two ways: |
|
- by truncating a piece of context that's too long, determined by |
|
.options.max_lines_per_piece |
|
- immediately after the signature piece if Options.include_signature is true |
|
and the following piece isn't already part of the included pieces. |
|
|
|
The Line objects are all within the ranges from .included_pieces. |
|
""" |
|
pieces = self.included_pieces |
|
if not pieces: |
|
return [] |
|
|
|
add_empty_lines = self.options.blank_lines in (BlankLines.VISIBLE, BlankLines.SINGLE) |
|
prev_piece = None |
|
result = [] |
|
for i, piece in enumerate(pieces): |
|
if ( |
|
i == 1 |
|
and self.scope |
|
and pieces[0] == self.scope_pieces[0] |
|
and pieces[1] != self.scope_pieces[1] |
|
): |
|
result.append(LINE_GAP) |
|
elif prev_piece and add_empty_lines and piece.start > prev_piece.stop: |
|
if self.options.blank_lines == BlankLines.SINGLE: |
|
result.append(BlankLineRange(prev_piece.stop, piece.start-1)) |
|
else: |
|
for lineno in range(prev_piece.stop, piece.start): |
|
result.append(Line(self, lineno)) |
|
|
|
lines = [Line(self, i) for i in piece] |
|
if piece != self.executing_piece: |
|
lines = truncate( |
|
lines, |
|
max_length=self.options.max_lines_per_piece, |
|
middle=[LINE_GAP], |
|
) |
|
result.extend(lines) |
|
prev_piece = piece |
|
|
|
real_lines = [ |
|
line |
|
for line in result |
|
if isinstance(line, Line) |
|
] |
|
|
|
text = "\n".join( |
|
line.text |
|
for line in real_lines |
|
) |
|
dedented_lines = dedent(text).splitlines() |
|
leading_indent = len(real_lines[0].text) - len(dedented_lines[0]) |
|
for line in real_lines: |
|
line.leading_indent = leading_indent |
|
return result |
|
|
|
@cached_property |
|
def scope(self) -> Optional[ast.AST]: |
|
""" |
|
The AST node of the innermost function, class or module being executed. |
|
""" |
|
if not self.source.tree or not self.executing.statements: |
|
return None |
|
|
|
stmt = list(self.executing.statements)[0] |
|
while True: |
|
|
|
|
|
|
|
stmt = stmt.parent |
|
if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Module)): |
|
return stmt |
|
|
|
@cached_property |
|
def _pygmented_scope_lines(self) -> Optional[Tuple[int, List[str]]]: |
|
|
|
from pygments.formatters import HtmlFormatter |
|
|
|
formatter = self.options.pygments_formatter |
|
scope = self.scope |
|
assert_(formatter, ValueError("Must set a pygments formatter in Options")) |
|
assert_(scope) |
|
|
|
if isinstance(formatter, HtmlFormatter): |
|
formatter.nowrap = True |
|
|
|
atext = self.source.asttext() |
|
node = self.executing.node |
|
if node and getattr(formatter.style, "for_executing_node", False): |
|
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 = atext.get_text(scope) |
|
lines = _pygmented_with_ranges(formatter, code, ranges) |
|
|
|
start_line = self.source.line_range(scope)[0] |
|
|
|
return start_line, lines |
|
|
|
@cached_property |
|
def variables(self) -> List[Variable]: |
|
""" |
|
All Variable objects whose nodes are contained within .scope |
|
and whose values could be safely evaluated by pure_eval. |
|
""" |
|
if not self.scope: |
|
return [] |
|
|
|
evaluator = Evaluator.from_frame(self.frame) |
|
scope = self.scope |
|
node_values = [ |
|
pair |
|
for pair in evaluator.find_expressions(scope) |
|
if is_expression_interesting(*pair) |
|
] |
|
|
|
if isinstance(scope, (ast.FunctionDef, ast.AsyncFunctionDef)): |
|
for node in ast.walk(scope.args): |
|
if not isinstance(node, ast.arg): |
|
continue |
|
name = node.arg |
|
try: |
|
value = evaluator.names[name] |
|
except KeyError: |
|
pass |
|
else: |
|
node_values.append((node, value)) |
|
|
|
|
|
def get_text(n): |
|
if isinstance(n, ast.arg): |
|
return n.arg |
|
else: |
|
return self.source.asttext().get_text(n) |
|
|
|
def normalise_node(n): |
|
try: |
|
|
|
return ast.parse('(' + get_text(n) + ')') |
|
except Exception: |
|
return n |
|
|
|
grouped = group_by_key_func( |
|
node_values, |
|
lambda nv: ast.dump(normalise_node(nv[0])), |
|
) |
|
|
|
result = [] |
|
for group in grouped.values(): |
|
nodes, values = zip(*group) |
|
value = values[0] |
|
text = get_text(nodes[0]) |
|
if not text: |
|
continue |
|
result.append(Variable(text, nodes, value)) |
|
|
|
return result |
|
|
|
@cached_property |
|
def variables_by_lineno(self) -> Mapping[int, List[Tuple[Variable, ast.AST]]]: |
|
""" |
|
A mapping from 1-based line numbers to lists of pairs: |
|
- A Variable object |
|
- A specific AST node from the variable's .nodes list that's |
|
in the line at that line number. |
|
""" |
|
result = defaultdict(list) |
|
for var in self.variables: |
|
for node in var.nodes: |
|
for lineno in range(*self.source.line_range(node)): |
|
result[lineno].append((var, node)) |
|
return result |
|
|
|
@cached_property |
|
def variables_in_lines(self) -> List[Variable]: |
|
""" |
|
A list of Variable objects contained within the lines returned by .lines. |
|
""" |
|
return unique_in_order( |
|
var |
|
for line in self.lines |
|
if isinstance(line, Line) |
|
for var, node in self.variables_by_lineno[line.lineno] |
|
) |
|
|
|
@cached_property |
|
def variables_in_executing_piece(self) -> List[Variable]: |
|
""" |
|
A list of Variable objects contained within the lines |
|
in the range returned by .executing_piece. |
|
""" |
|
return unique_in_order( |
|
var |
|
for lineno in self.executing_piece |
|
for var, node in self.variables_by_lineno[lineno] |
|
) |
|
|