diff --git a/docs/_static/images/tree_example.png b/docs/_static/images/tree_example.png index ba8d83c18c..34a7368c38 100644 Binary files a/docs/_static/images/tree_example.png and b/docs/_static/images/tree_example.png differ diff --git a/docs/tree.rst b/docs/tree.rst index 88a9478e1a..ed5e17caf3 100644 --- a/docs/tree.rst +++ b/docs/tree.rst @@ -6,9 +6,9 @@ the tracked process at the time when its memory usage was at its peak. .. image:: _static/images/tree_example.png -The tree reporter shows some statistics followed by a tree representation of -the allocated memory. Several aspects are important when interpreting the tree -representation: +The tree reporter shows an interactive terminal applocation displaying a tree +representation of the allocated memory. Several aspects are important when +interpreting the tree representation: * Only the 10 source locations responsible for the most allocated bytes are displayed. This is configurable with the ``--biggest-allocs`` command line @@ -17,17 +17,24 @@ representation: calculated based only on the allocations that are shown. Since any allocation not big enough to be shown will not be included there, the reported total memory of the root node is normally less than the process's peak memory size. -* Call chains of one node are collapsed for better readability. This means that - branches in the tree where each node has only one child are collapsed and a - special node is shown to reflect this. The hidden frames **never** correspond - to frames that contained one of the source locations with the biggest - allocations. The hidden frames are always callers of functions where the reported - allocation happened. * The "📂" icon represents a frame that is a **caller** of a function where an allocation happened while the "📄" icon represents a frame that allocated memory. * Frames are colored based on their reported memory usage percentage, from red (most bytes allocated) to green (fewest). +* You can interact with the application using the following keys: + + * You can navigate the tree using the arrow keys. Pressing the up arrow key + will move up one level in the tree, while pressing the down arrow key will + move down one level. When a selected node is changed, the panel on the right + will be updated to show the source code of the selected frame and some metadata + about the allocations assigned to that frame. + * Pressing the 'e' key will expand nodes and their children recursively until a node with + more than one child is found. This can be used to quickly expand the tree. + * Pressing the 'i' key will hide all nodes that belong to the import system and their + children. + * Presing the 'u' key will show all nodes that are marked as "uninteresting". + Basic Usage ----------- diff --git a/news/499.feature.rst b/news/499.feature.rst new file mode 100644 index 0000000000..3dc2c4c961 --- /dev/null +++ b/news/499.feature.rst @@ -0,0 +1 @@ +Port the tree reporter to be an interactive Textual App. diff --git a/src/memray/commands/tree.py b/src/memray/commands/tree.py index 32a9a1b16f..cc1f459198 100644 --- a/src/memray/commands/tree.py +++ b/src/memray/commands/tree.py @@ -3,11 +3,8 @@ from pathlib import Path from textwrap import dedent -from rich import print as rprint - from memray import FileReader from memray._errors import MemrayCommandError -from memray._memray import size_fmt from memray.commands.common import warn_if_not_enough_symbols from memray.reporters.tree import TreeReporter @@ -80,14 +77,4 @@ def run(self, args: argparse.Namespace, parser: argparse.ArgumentParser) -> None f"Failed to parse allocation records in {result_path}\nReason: {e}", exit_code=1, ) - print() - header = "Allocation metadata" - rprint(f"{header}\n{'-'*len(header)}") - rprint(f"Command line arguments: '{reader.metadata.command_line}'") - rprint(f"Peak memory size: {size_fmt(reader.metadata.peak_memory)}") - rprint(f"Number of allocations: {reader.metadata.total_allocations}") - print() - header = f"Biggest {args.biggest_allocs} allocations:" - rprint(header) - rprint("-" * len(header)) reporter.render() diff --git a/src/memray/reporters/tree.py b/src/memray/reporters/tree.py index 38f356ca6b..8d6c2d5150 100644 --- a/src/memray/reporters/tree.py +++ b/src/memray/reporters/tree.py @@ -1,19 +1,41 @@ +import linecache import sys from dataclasses import dataclass from dataclasses import field +from dataclasses import replace from typing import IO +from typing import Any +from typing import Callable from typing import Dict +from typing import Iterable from typing import Iterator -from typing import List from typing import Optional from typing import Tuple -import rich -import rich.tree +from textual.app import App +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Container +from textual.containers import Grid +from textual.containers import Horizontal +from textual.containers import Vertical +from textual.dom import DOMNode +from textual.reactive import reactive +from textual.widget import Widget +from textual.widgets import Footer +from textual.widgets import Label +from textual.widgets import ListItem +from textual.widgets import ListView +from textual.widgets import TextArea +from textual.widgets import Tree +from textual.widgets.tree import TreeNode from memray import AllocationRecord from memray._memray import size_fmt from memray.reporters.frame_tools import is_cpython_internal +from memray.reporters.frame_tools import is_frame_from_import_system +from memray.reporters.frame_tools import is_frame_interesting +from memray.reporters.tui import _filename_to_module_name MAX_STACKS = int(sys.getrecursionlimit() // 2.5) @@ -24,29 +46,279 @@ @dataclass class Frame: + """A frame in the tree""" + location: StackElement value: int children: Dict[StackElement, "Frame"] = field(default_factory=dict) n_allocations: int = 0 thread_id: str = "" interesting: bool = True - group: List["Frame"] = field(default_factory=list) - - def collapse_tree(self) -> "Frame": - if len(self.children) == 0: - return self - elif len(self.children) == 1 and ROOT_NODE != self.location: - [[key, child]] = self.children.items() - self.children.pop(key) - new_node = child.collapse_tree() - new_node.group.append(self) - return new_node + import_system: bool = False + + +class FrameDetailScreen(Widget): + """A screen that displays information about a frame""" + + frame = reactive(Frame(location=ROOT_NODE, value=0)) + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.__is_mounted = False + + def on_mount(self) -> None: + self.__is_mounted = True + + def watch_frame(self) -> None: + if not self.__is_mounted or self.frame is None: + return + + function, file, line = self.frame.location + text = self.query_one("#textarea", TextArea) + delta = text.size.height // 2 + lines = linecache.getlines(file)[line - delta : line + delta] + + self.query_one("#function", Label).update(f":compass: Function: {function}") + self.query_one("#location", Label).update( + f":compass: Location: {_filename_to_module_name(file)}:{line}" + ) + self.query_one("#allocs", Label).update( + f":floppy_disk: Allocations: {self.frame.n_allocations}" + ) + self.query_one("#size", Label).update( + f":package: Size: {size_fmt(self.frame.value)}" + ) + self.query_one("#thread", Label).update( + f":thread: Thread: {self.frame.thread_id}" + ) + text.text = "\n".join(tuple(line.rstrip() for line in lines)) + text.select_line((delta - 1)) + text.show_line_numbers = False + + def compose(self) -> ComposeResult: + if self.frame is None: + return + function, file, line = self.frame.location + delta = 3 + lines = linecache.getlines(file)[line - delta : line + delta] + text = TextArea( + "\n".join(lines), language="python", theme="dracula", id="textarea" + ) + text.select_line(delta + 1) + text.show_line_numbers = False + text.can_focus = False + text.cursor_blink = False + + list_view = ListView( + ListItem(Label(f":compass: Function: {function}", id="function")), + ListItem(Label(f":compass: Location: {file}:{line}", id="location")), + ListItem( + Label( + f":floppy_disk: Allocations: {self.frame.n_allocations}", + id="allocs", + ) + ), + ListItem(Label(f":package: Size: {size_fmt(self.frame.value)}", id="size")), + ListItem(Label(f":thread: Thread: {self.frame.thread_id}", id="thread")), + ) + list_view.can_focus = False + + yield Grid( + text, + list_view, + id="frame-detail-grid", + ) + + +class FrameTree(Tree[Frame]): + def on_tree_node_selected(self, node: Tree.NodeSelected[Frame]) -> None: + if node.node.data is not None: + self.app.query_one(FrameDetailScreen).frame = node.node.data + + def on_tree_node_highlighted(self, node: Tree.NodeHighlighted[Frame]) -> None: + if node.node.data is not None: + self.app.query_one(FrameDetailScreen).frame = node.node.data + + +def node_is_interesting(node: Frame) -> bool: + return node.interesting + + +def node_is_not_import_system(node: Frame) -> bool: + return not node.import_system + + +class TreeApp(App[None]): + BINDINGS = [ + Binding(key="q", action="quit", description="Quit the app"), + Binding(key="i", action="hide_import_system", description="Hide import system"), + Binding(key="u", action="hide_uninteresting", description="Hide uninteresting"), + Binding( + key="e", action="expand_linear_group", description="Expand linear group" + ), + ] + + DEFAULT_CSS = """ + Label { + padding: 1 3; + } + + #frame-detail-grid { + grid-size: 1 2; + grid-gutter: 1 2; + padding: 0 1; + border: thick $background 80%; + background: $surface; + } + + #detailcol { + width: 30%; + } + + TextArea { + scrollbar-size-vertical: 0; + } + """ + + def __init__( + self, + data: Frame, + ): + super().__init__() + self.data = data + self.import_system_filter: Optional[Callable[[Frame], bool]] = None + self.uninteresting_filter: Optional[ + Callable[[Frame], bool] + ] = node_is_interesting + + def expand_bigger_nodes(self, node: TreeNode[Frame]) -> None: + if not node.children: + return + biggest_child = max( + node.children, key=lambda child: 0 if not child.data else child.data.value + ) + biggest_child.toggle() + self.expand_bigger_nodes(biggest_child) + + def compose(self) -> ComposeResult: + tree = FrameTree(self.frame_text(self.data), self.data) + self.repopulate_tree(tree) + yield Horizontal( + Vertical( + Container(tree, id="treec"), + ), + Vertical(FrameDetailScreen(), id="detailcol"), + ) + yield Footer() + + def repopulate_tree(self, tree: FrameTree) -> None: + tree.clear() + self.add_children(tree.root, self.data.children.values()) + tree.root.expand() + tree.select_node(tree.root) + self.expand_bigger_nodes(tree.root) + + def action_expand_linear_group(self) -> None: + tree = self.query_one(FrameTree) + assert tree + current_node = tree.cursor_node + while current_node: + current_node.toggle() + if len(current_node.children) != 1: + break + current_node = current_node.children[0] + + def frame_text(self, node: Frame) -> str: + if node.value == 0: + return "" + + value = node.value + root_data = self.data + size_str = f"{size_fmt(value)} ({100 * value / root_data.value:.2f} %)" + function, file, lineno = node.location + icon = ":page_facing_up:" if len(node.children) == 0 else ":open_file_folder:" + return ( + "{icon}[{info_color}] {size} " + "[bold]{function}[/bold][/{info_color}] " + "[dim cyan]{code_position}[/dim cyan]".format( + icon=icon, + size=size_str, + info_color=_info_color(node, root_data), + function=function, + code_position=f"{_filename_to_module_name(file)}:{lineno}" + if lineno != 0 + else file, + ) + ) + + def add_children(self, tree: TreeNode[Frame], children: Iterable[Frame]) -> None: + if self.import_system_filter is not None: + children = tuple(filter(self.import_system_filter, children)) + + for child in children: + if self.uninteresting_filter is None or self.uninteresting_filter(child): + if not tree.allow_expand: + tree.allow_expand = True + new_tree = tree.add( + self.frame_text(child), data=child, allow_expand=False + ) + else: + new_tree = tree + + self.add_children(new_tree, child.children.values()) + + def action_hide_import_system(self) -> None: + if self.import_system_filter is None: + self.import_system_filter = node_is_not_import_system else: - self.children = { - location: child.collapse_tree() - for location, child in self.children.items() - } - return self + self.import_system_filter = None + + self.redraw_footer() + self.repopulate_tree(self.query_one(FrameTree)) + + def action_hide_uninteresting(self) -> None: + if self.uninteresting_filter is None: + self.uninteresting_filter = node_is_interesting + else: + self.uninteresting_filter = None + + self.redraw_footer() + self.repopulate_tree(self.query_one(FrameTree)) + + def redraw_footer(self) -> None: + # Hack: trick the Footer into redrawing itself + self.app.query_one(Footer).highlight_key = "q" + self.app.query_one(Footer).highlight_key = None + + @property + def namespace_bindings(self) -> Dict[str, Tuple[DOMNode, Binding]]: + bindings = super().namespace_bindings.copy() + if self.import_system_filter is not None: + node, binding = bindings["i"] + bindings["i"] = ( + node, + replace(binding, description="Show import system"), + ) + if self.uninteresting_filter is not None: + node, binding = bindings["u"] + bindings["u"] = ( + node, + replace(binding, description="Show uninteresting"), + ) + + return bindings + + +def _info_color(node: Frame, root_node: Frame) -> str: + proportion_of_total = node.value / root_node.value + if proportion_of_total > 0.6: + return "red" + elif proportion_of_total > 0.2: + return "yellow" + elif proportion_of_total > 0.05: + return "green" + else: + return "bright_green" class TreeReporter: @@ -62,7 +334,7 @@ def from_snapshot( biggest_allocs: int = 10, native_traces: bool, ) -> "TreeReporter": - data = Frame(location=ROOT_NODE, value=0) + data = Frame(location=ROOT_NODE, value=0, import_system=False, interesting=True) for record in sorted(allocations, key=lambda alloc: alloc.size, reverse=True)[ :biggest_allocs ]: @@ -79,8 +351,17 @@ def from_snapshot( for index, stack_frame in enumerate(reversed(stack)): if is_cpython_internal(stack_frame): continue + is_import_system = is_frame_from_import_system(stack_frame) + is_interesting = ( + is_frame_interesting(stack_frame) and not is_import_system + ) if stack_frame not in current_frame.children: - node = Frame(value=0, location=stack_frame) + node = Frame( + value=0, + location=stack_frame, + import_system=is_import_system, + interesting=is_interesting, + ) current_frame.children[stack_frame] = node current_frame = current_frame.children[stack_frame] @@ -91,64 +372,14 @@ def from_snapshot( if index > MAX_STACKS: break - return cls(data.collapse_tree()) + return cls(data) + + def get_app(self) -> TreeApp: + return TreeApp(self.data) def render( self, *, file: Optional[IO[str]] = None, ) -> None: - tree = self.make_rich_node(node=self.data) - rich.print(tree, file=file) - - def make_rich_node( - self, - node: Frame, - parent_tree: Optional[rich.tree.Tree] = None, - root_node: Optional[Frame] = None, - depth: int = 0, - ) -> rich.tree.Tree: - if node.value == 0: - return rich.tree.Tree("") - if root_node is None: - root_node = node - - if node.group: - libs = {frame.location[1] for frame in node.group} - text = f"[blue][[{len(node.group)} frames hidden in {len(libs)} file(s)]][/blue]" - parent_tree = ( - rich.tree.Tree(text) if parent_tree is None else parent_tree.add(text) - ) - value = node.value - size_str = f"{size_fmt(value)} ({100 * value / root_node.value:.2f} %)" - function, file, lineno = node.location - icon = ":page_facing_up:" if len(node.children) == 0 else ":open_file_folder:" - frame_text = ( - "{icon}[{info_color}] {size} " - "[bold]{function}[/bold][/{info_color}] " - "[dim cyan]{code_position}[/dim cyan]".format( - icon=icon, - size=size_str, - info_color=self._info_color(node, root_node), - function=function, - code_position=f"{file}:{lineno}" if lineno != 0 else file, - ) - ) - if parent_tree is None: - parent_tree = new_tree = rich.tree.Tree(frame_text) - else: - new_tree = parent_tree.add(frame_text) - for child in node.children.values(): - self.make_rich_node(child, new_tree, depth=depth + 1, root_node=root_node) - return parent_tree - - def _info_color(self, node: Frame, root_node: Frame) -> str: - proportion_of_total = node.value / root_node.value - if proportion_of_total > 0.6: - return "red" - elif proportion_of_total > 0.2: - return "yellow" - elif proportion_of_total > 0.05: - return "green" - else: - return "bright_green" + self.get_app().run() diff --git a/tests/integration/test_main.py b/tests/integration/test_main.py index af7476410e..6a5a70aaf2 100644 --- a/tests/integration/test_main.py +++ b/tests/integration/test_main.py @@ -870,7 +870,7 @@ def test_tree_generated(self, tmp_path, simple_test_file): ) # THEN - assert "frames hidden" in output + assert output def test_temporary_allocations_tree(self, tmp_path, simple_test_file): # GIVEN diff --git a/tests/unit/__snapshots__/test_tree_reporter.ambr b/tests/unit/__snapshots__/test_tree_reporter.ambr new file mode 100644 index 0000000000..fd08eb2378 --- /dev/null +++ b/tests/unit/__snapshots__/test_tree_reporter.ambr @@ -0,0 +1,2818 @@ +# serializer version: 1 +# name: TestTUILooks.test_basic + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TreeApp + + + + + + + + + + ▼ 📂 1.000KB (100.00 %) <ROOT>▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + ┗━━ ▼ 📂 1.000KB (100.00 %) grandparentfun.py:4 + ┗━━ ▼ 📂 1.000KB (100.00 %) parentfun.py:8 + ┗━━ 📄 1.000KB (100.00 %) mefun.py:12 + + + + + + + + + + + + + + + + + + + + + + + + + + + + 🧭 Function: <ROOT> + + + 🧭 Location: :0 + + + 💾 Allocations: 1 + + + 📦 Size: 1.000KB + + + 🧵 Thread:  + + + + + + + + + + + + + + + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +  Q  Quit the app  I  Hide import system  U  Show uninteresting  E  Expand linear group  + + + + + ''' +# --- +# name: TestTUILooks.test_basic_node_selected_leaf + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TreeApp + + + + + + + + + + ▼ 📂 1.000KB (100.00 %) <ROOT>▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + └── ▼ 📂 1.000KB (100.00 %) grandparentfun.py:4       yield prime              + └── ▼ 📂 1.000KB (100.00 %) parentfun.py:8        numbers = filter(lambda  + └── 📄 1.000KB (100.00 %) mefun.py:12 + + + + + + + + + + + + + + + + + + + + + + + + + + + + 🧭 Function: me + + + 🧭 Location: fun.py:12 + + + 💾 Allocations: 1 + + + 📦 Size: 1.000KB + + + 🧵 Thread: 0x1 + + + + + + + + + + + + + + + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +  Q  Quit the app  I  Hide import system  U  Show uninteresting  E  Expand linear group  + + + + + ''' +# --- +# name: TestTUILooks.test_basic_node_selected_not_leaf + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TreeApp + + + + + + + + + + ▼ 📂 1.000KB (100.00 %) <ROOT>▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + └── ▼ 📂 1.000KB (100.00 %) grandparentfun.py:4def generate_primes():           + └── ▼ 📂 1.000KB (100.00 %) parentfun.py:8    numbers = itertools.count(2) + ┗━━ 📄 1.000KB (100.00 %) mefun.py:12    while True:                  +         prime = next(numbers)    +         yield prime              +         numbers = filter(lambda  + + + + + + + + + + + + + + + + + + + + + + + + + 🧭 Function: parent + + + 🧭 Location: fun.py:8 + + + 💾 Allocations: 1 + + + 📦 Size: 1.000KB + + + 🧵 Thread: 0x1 + + + + + + + + + + + + + + + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +  Q  Quit the app  I  Hide import system  U  Show uninteresting  E  Expand linear group  + + + + + ''' +# --- +# name: TestTUILooks.test_hide_import_system + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TreeApp + + + + + + + + + + ▼ 📂 11.000KB (100.00 %) <ROOT>▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + ┗━━ ▼ 📂 10.000KB (90.91 %) grandparent2fun2.py:4 + ┗━━ ▼ 📂 10.000KB (90.91 %) parent2fun2.py:8 + ┗━━ 📄 10.000KB (90.91 %) me2fun2.py:12 + + + + + + + + + + + + + + + + + + + + + + + + + + + + 🧭 Function: <ROOT> + + + 🧭 Location: :0 + + + 💾 Allocations: 2 + + + 📦 Size: 11.000KB + + + 🧵 Thread:  + + + + + + + + + + + + + + + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +  Q  Quit the app  I  Show import system  U  Show uninteresting  E  Expand linear group  + + + + + ''' +# --- +# name: TestTUILooks.test_select_screen + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TreeApp + + + + + + + + + + ▼ 📂 1.000KB (100.00 %) <ROOT>▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + └── ▼ 📂 1.000KB (100.00 %) grandparent2fun2.py:4import itertools                 + └── ▼ 📂 1.000KB (100.00 %) parent2fun2.py:8def generate_primes():           + └── 📄 1.000KB (100.00 %) me2func2.py:4    numbers = itertools.count(2) +     while True:                  +         prime = next(numbers)    +         yield prime              +         numbers = filter(lambda  + + + + + + + + + + + + + + + + + + + + + + + + 🧭 Function: me2 + + + 🧭 Location: func2.py:4 + + + 💾 Allocations: 1 + + + 📦 Size: 1.000KB + + + 🧵 Thread: 0x1 + + + + + + + + + + + + + + + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +  Q  Quit the app  I  Hide import system  U  Show uninteresting  E  Expand linear group  + + + + + ''' +# --- +# name: TestTUILooks.test_show_uninteresting + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TreeApp + + + + + + + + + + ▼ 📂 11.000KB (100.00 %) <ROOT>▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + ┣━━ ▼ 📂 10.000KB (90.91 %) grandparent2fun2.py:4 + ┃   ┗━━ ▼ 📂 10.000KB (90.91 %) parent2fun2.py:8 + ┃   ┗━━ 📄 10.000KB (90.91 %) me2fun2.py:12 + ┗━━ ▶ 📂 1.000KB (9.09 %) erunpy.py:5 + + + + + + + + + + + + + + + + + + + + + + + + + + + 🧭 Function: <ROOT> + + + 🧭 Location: :0 + + + 💾 Allocations: 2 + + + 📦 Size: 11.000KB + + + 🧵 Thread:  + + + + + + + + + + + + + + + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +  Q  Quit the app  I  Hide import system  U  Hide uninteresting  E  Expand linear group  + + + + + ''' +# --- +# name: TestTUILooks.test_show_uninteresting_and_hide_import_system + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TreeApp + + + + + + + + + + ▼ 📂 11.000KB (100.00 %) <ROOT>▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + ┣━━ ▼ 📂 10.000KB (90.91 %) crunpy.py:5 + ┃   ┗━━ ▼ 📂 10.000KB (90.91 %) brunpy.py:2 + ┃   ┗━━ ▼ 📂 10.000KB (90.91 %) arunpy.py:1 + ┃   ┗━━ 📂 10.000KB (90.91 %) Asome other frame:4 + ┗━━ ▶ 📂 1.000KB (9.09 %) grandparent2fun2.py:4 + + + + + + + + + + + + + + + + + + + + + + + + + + 🧭 Function: <ROOT> + + + 🧭 Location: :0 + + + 💾 Allocations: 2 + + + 📦 Size: 11.000KB + + + 🧵 Thread:  + + + + + + + + + + + + + + + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +  Q  Quit the app  I  Show import system  U  Hide uninteresting  E  Expand linear group  + + + + + ''' +# --- +# name: TestTUILooks.test_two_chains + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TreeApp + + + + + + + + + + ▼ 📂 11.000KB (100.00 %) <ROOT>▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + ┣━━ ▼ 📂 10.000KB (90.91 %) grandparent2fun2.py:4 + ┃   ┗━━ ▼ 📂 10.000KB (90.91 %) parent2fun2.py:8 + ┃   ┗━━ 📄 10.000KB (90.91 %) me2fun2.py:12 + ┗━━ ▶ 📂 1.000KB (9.09 %) grandparentfun.py:4 + + + + + + + + + + + + + + + + + + + + + + + + + + + 🧭 Function: <ROOT> + + + 🧭 Location: :0 + + + 💾 Allocations: 2 + + + 📦 Size: 11.000KB + + + 🧵 Thread:  + + + + + + + + + + + + + + + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +  Q  Quit the app  I  Hide import system  U  Show uninteresting  E  Expand linear group  + + + + + ''' +# --- +# name: TestTUILooks.test_two_chains_after_expanding_second + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TreeApp + + + + + + + + + + ▼ 📂 11.000KB (100.00 %) <ROOT>▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + ├── ▼ 📂 10.000KB (90.91 %) grandparent2fun2.py:4import itertools                 + │   └── ▼ 📂 10.000KB (90.91 %) parent2fun2.py:8def generate_primes():           + │   └── 📄 10.000KB (90.91 %) me2fun2.py:12    numbers = itertools.count(2) + └── ▼ 📂 1.000KB (9.09 %) efun.py:5    while True:                  + ┗━━ ▼ 📂 1.000KB (9.09 %) dfun.py:4        prime = next(numbers)    + ┗━━ ▼ 📂 1.000KB (9.09 %) cfun.py:3        yield prime              + ┗━━ ▼ 📂 1.000KB (9.09 %) bfun.py:2        numbers = filter(lambda  + ┗━━ 📄 1.000KB (9.09 %) afun.py:1 + + + + + + + + + + + + + + + + + + + + + + + 🧭 Function: e + + + 🧭 Location: fun.py:5 + + + 💾 Allocations: 1 + + + 📦 Size: 1.000KB + + + 🧵 Thread: 0x1 + + + + + + + + + + + + + + + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +  Q  Quit the app  I  Hide import system  U  Show uninteresting  E  Expand linear group  + + + + + ''' +# --- diff --git a/tests/unit/test_tree_reporter.py b/tests/unit/test_tree_reporter.py index 5e5006ed4a..e8498bedc7 100644 --- a/tests/unit/test_tree_reporter.py +++ b/tests/unit/test_tree_reporter.py @@ -1,240 +1,26 @@ -import sys -from io import StringIO - +from dataclasses import dataclass +from textwrap import dedent +from typing import Awaitable +from typing import Callable +from typing import Iterable +from typing import Iterator +from typing import List +from typing import Optional +from typing import Tuple +from unittest.mock import patch + +import pytest +from textual.pilot import Pilot +from textual.widgets import Tree +from textual.widgets.tree import TreeNode + +from memray import AllocationRecord from memray import AllocatorType from memray.reporters.tree import MAX_STACKS -from memray.reporters.tree import ROOT_NODE from memray.reporters.tree import Frame from memray.reporters.tree import TreeReporter from tests.utils import MockAllocationRecord - - -class TestCollapseTree: - def test_single_node(self): - location_1 = ("A", "A", 1) - node_1 = Frame(location=location_1, value=10, children={}) - - # WHEN - - tree = node_1.collapse_tree() - - # THEN - - assert tree == Frame( - location=("A", "A", 1), - value=10, - children={}, - n_allocations=0, - thread_id="", - interesting=True, - group=[], - ) - - def test_many_nodes(self): - # GIVEN - location_1 = ("A", "A", 1) - node_1 = Frame(location=location_1, value=10, children={}) - location_2 = ("B", "B", 1) - node_2 = Frame(location=location_2, value=10, children={}) - location_3 = ("C", "C", 1) - node_3 = Frame(location=location_3, value=10, children={}) - root = Frame( - location=("", "", 0), - value=10, - children={location_1: node_1, location_2: node_2, location_3: node_3}, - ) - # WHEN - - tree = root.collapse_tree() - - # THEN - - assert tree.location == ("", "", 0) - assert tree.value == 10 - assert [node.location for node in tree.children.values()] == [ - location_1, - location_2, - location_3, - ] - assert tree.group == [] - - def test_collapse_line(self): - # GIVEN - - location_1 = ("A", "A", 1) - node_1 = Frame(location=location_1, value=10, children={}) - location_2 = ("B", "B", 1) - node_2 = Frame(location=location_2, value=10, children={location_1: node_1}) - location_3 = ("C", "C", 1) - node_3 = Frame(location=location_3, value=10, children={location_2: node_2}) - # WHEN - - tree = node_3.collapse_tree() - - # THEN - - assert tree.location == location_1 - assert tree.value == 10 - assert tree.children == {} - assert tree.group == [node_2, node_3] - - def test_root_is_not_collapsed(self): - # GIVEN - - location_1 = ("A", "A", 1) - node_1 = Frame(location=location_1, value=10, children={}) - location_2 = ("B", "B", 1) - node_2 = Frame(location=location_2, value=10, children={location_1: node_1}) - location_3 = ("C", "C", 1) - node_3 = Frame(location=location_3, value=10, children={location_2: node_2}) - root = Frame(location=ROOT_NODE, value=10, children={location_3: node_3}) - # WHEN - - tree = root.collapse_tree() - - # THEN - - assert tree.location == ROOT_NODE - assert tree.value == 10 - assert len(tree.children) == 1 - assert not tree.group - - (child,) = tree.children.values() - assert child.location == location_1 - assert child.group == [node_2, node_3] - assert child.value == 10 - - def test_collapse_line_with_branching_root(self): - # GIVEN - - location_1 = ("A", "A", 1) - node_1 = Frame(location=location_1, value=10, children={}) - location_2 = ("B", "B", 1) - node_2 = Frame(location=location_2, value=10, children={location_1: node_1}) - location_3 = ("C", "C", 1) - node_3 = Frame(location=location_3, value=10, children={location_2: node_2}) - location_4 = ("D", "D", 1) - node_4 = Frame(location=location_4, value=10, children={}) - root = Frame( - location=("", "", 0), - value=10, - children={location_3: node_3, location_4: node_4}, - ) - # WHEN - - tree = root.collapse_tree() - - # THEN - - assert tree.location == ("", "", 0) - assert tree.value == 10 - assert len(tree.children) == 2 - assert tree.group == [] - assert [node.location for node in tree.children.values()] == [ - location_1, - location_4, - ] - - branch1, branch2 = tree.children.values() - assert branch1.location == location_1 - assert branch1.value == 10 - assert branch1.children == {} - assert branch1.group == [node_2, node_3] - - assert branch2.location == location_4 - assert branch2.value == 10 - assert branch2.children == {} - assert branch2.group == [] - - def test_no_lines(self): - location_1 = ("A", "A", 1) - node_1 = Frame(location=location_1, value=10, children={}) - location_2 = ("B", "B", 1) - node_2 = Frame(location=location_2, value=10, children={}) - location_3 = ("C", "C", 1) - node_3 = Frame( - location=location_3, - value=10, - children={location_1: node_1, location_2: node_2}, - ) - location_4 = ("D", "D", 1) - node_4 = Frame(location=location_4, value=10, children={}) - root = Frame( - location=("", "", 0), - value=10, - children={location_3: node_3, location_4: node_4}, - ) - # WHEN - - tree = root.collapse_tree() - - # THEN - - assert tree.location == ("", "", 0) - assert tree.value == 10 - assert len(tree.children) == 2 - assert tree.group == [] - assert [node.location for node in tree.children.values()] == [ - location_3, - location_4, - ] - - branch1, branch2 = tree.children.values() - assert branch1.location == location_3 - assert branch1.value == 10 - assert [node.location for node in branch1.children.values()] == [ - location_1, - location_2, - ] - - assert branch2.location == location_4 - assert branch2.value == 10 - assert branch2.children == {} - assert branch2.group == [] - - def test_two_lines(self): - location_a1 = ("A1", "A1", 1) - node_a1 = Frame(location=location_a1, value=10, children={}) - location_a2 = ("B1", "B1", 1) - node_a2 = Frame(location=location_a2, value=10, children={location_a1: node_a1}) - location_a3 = ("C1", "C1", 1) - node_a3 = Frame(location=location_a3, value=10, children={location_a2: node_a2}) - location_b1 = ("A2", "A2", 1) - node_b1 = Frame(location=location_b1, value=10, children={}) - location_b2 = ("B2", "B2", 1) - node_b2 = Frame(location=location_b2, value=10, children={location_b1: node_b1}) - location_b3 = ("C2", "C2", 1) - node_b3 = Frame(location=location_b3, value=10, children={location_b2: node_b2}) - root = Frame( - location=("", "", 0), - value=10, - children={location_a3: node_a3, location_b3: node_b3}, - ) - - # WHEN - - tree = root.collapse_tree() - - # THEN - - assert tree.location == ("", "", 0) - assert tree.value == 10 - assert [node.location for node in tree.children.values()] == [ - location_a1, - location_b1, - ] - assert tree.group == [] - - branch1, branch2 = tree.children.values() - assert branch1.location == location_a1 - assert branch1.value == 10 - assert branch1.children == {} - assert branch1.group == [node_a2, node_a3] - - assert branch2.location == location_b1 - assert branch2.value == 10 - assert branch2.children == {} - assert branch2.group == [node_b2, node_b3] +from tests.utils import async_run class TestTreeReporter: @@ -247,7 +33,6 @@ def test_works_with_no_allocations(self): n_allocations=0, thread_id="", interesting=True, - group=[], ) def test_biggest_allocations(self): @@ -284,7 +69,7 @@ def test_biggest_allocations(self): n_allocations=1, thread_id="0x1", interesting=True, - group=[], + import_system=False, ), ("function_998", "fun.py", 12): Frame( location=("function_998", "fun.py", 12), @@ -293,7 +78,7 @@ def test_biggest_allocations(self): n_allocations=1, thread_id="0x1", interesting=True, - group=[], + import_system=False, ), ("function_997", "fun.py", 12): Frame( location=("function_997", "fun.py", 12), @@ -302,13 +87,13 @@ def test_biggest_allocations(self): n_allocations=1, thread_id="0x1", interesting=True, - group=[], + import_system=False, ), }, n_allocations=3, thread_id="", interesting=True, - group=[], + import_system=False, ) def test_works_with_single_call(self): @@ -340,38 +125,39 @@ def test_works_with_single_call(self): value=1024, children={ ("grandparent", "fun.py", 4): Frame( - location=("me", "fun.py", 12), + location=("grandparent", "fun.py", 4), value=1024, - children={}, - n_allocations=1, - thread_id="0x1", - interesting=True, - group=[ - Frame( + children={ + ("parent", "fun.py", 8): Frame( location=("parent", "fun.py", 8), value=1024, - children={}, - n_allocations=1, - thread_id="0x1", - interesting=True, - group=[], - ), - Frame( - location=("grandparent", "fun.py", 4), - value=1024, - children={}, + children={ + ("me", "fun.py", 12): Frame( + location=("me", "fun.py", 12), + value=1024, + children={}, + n_allocations=1, + thread_id="0x1", + interesting=True, + import_system=False, + ) + }, n_allocations=1, thread_id="0x1", interesting=True, - group=[], - ), - ], + import_system=False, + ) + }, + n_allocations=1, + thread_id="0x1", + interesting=True, + import_system=False, ) }, n_allocations=1, thread_id="", interesting=True, - group=[], + import_system=False, ) def test_uses_hybrid_stack_for_native_traces(self): @@ -401,38 +187,39 @@ def test_uses_hybrid_stack_for_native_traces(self): value=1024, children={ ("grandparent", "fun.c", 4): Frame( - location=("me", "fun.py", 12), + location=("grandparent", "fun.c", 4), value=1024, - children={}, - n_allocations=1, - thread_id="0x1", - interesting=True, - group=[ - Frame( + children={ + ("parent", "fun.pyx", 8): Frame( location=("parent", "fun.pyx", 8), value=1024, - children={}, - n_allocations=1, - thread_id="0x1", - interesting=True, - group=[], - ), - Frame( - location=("grandparent", "fun.c", 4), - value=1024, - children={}, + children={ + ("me", "fun.py", 12): Frame( + location=("me", "fun.py", 12), + value=1024, + children={}, + n_allocations=1, + thread_id="0x1", + interesting=True, + import_system=False, + ) + }, n_allocations=1, thread_id="0x1", interesting=True, - group=[], - ), - ], + import_system=False, + ) + }, + n_allocations=1, + thread_id="0x1", + interesting=True, + import_system=False, ) }, n_allocations=1, thread_id="", interesting=True, - group=[], + import_system=False, ) def test_works_with_multiple_stacks_from_same_caller(self): @@ -475,48 +262,48 @@ def test_works_with_multiple_stacks_from_same_caller(self): value=2048, children={ ("grandparent", "fun.py", 4): Frame( - location=("parent", "fun.py", 8), + location=("grandparent", "fun.py", 4), value=2048, children={ - ("me", "fun.py", 12): Frame( - location=("me", "fun.py", 12), - value=1024, - children={}, - n_allocations=1, - thread_id="0x1", - interesting=True, - group=[], - ), - ("sibling", "fun.py", 16): Frame( - location=("sibling", "fun.py", 16), - value=1024, - children={}, - n_allocations=1, + ("parent", "fun.py", 8): Frame( + location=("parent", "fun.py", 8), + value=2048, + children={ + ("me", "fun.py", 12): Frame( + location=("me", "fun.py", 12), + value=1024, + children={}, + n_allocations=1, + thread_id="0x1", + interesting=True, + import_system=False, + ), + ("sibling", "fun.py", 16): Frame( + location=("sibling", "fun.py", 16), + value=1024, + children={}, + n_allocations=1, + thread_id="0x1", + interesting=True, + import_system=False, + ), + }, + n_allocations=2, thread_id="0x1", interesting=True, - group=[], - ), + import_system=False, + ) }, n_allocations=2, thread_id="0x1", interesting=True, - group=[ - Frame( - location=("grandparent", "fun.py", 4), - value=2048, - children={}, - n_allocations=2, - thread_id="0x1", - interesting=True, - group=[], - ) - ], + import_system=False, ) }, n_allocations=2, thread_id="", interesting=True, - group=[], + import_system=False, ) def test_works_with_multiple_stacks_from_same_caller_two_frames_above(self): @@ -563,54 +350,54 @@ def test_works_with_multiple_stacks_from_same_caller_two_frames_above(self): value=2048, children={ ("parent_one", "fun.py", 8): Frame( - location=("me", "fun.py", 12), + location=("parent_one", "fun.py", 8), value=1024, - children={}, - n_allocations=1, - thread_id="0x1", - interesting=True, - group=[ - Frame( - location=("parent_one", "fun.py", 8), + children={ + ("me", "fun.py", 12): Frame( + location=("me", "fun.py", 12), value=1024, children={}, n_allocations=1, thread_id="0x1", interesting=True, - group=[], + import_system=False, ) - ], - ), - ("parent_two", "fun.py", 10): Frame( - location=("sibling", "fun.py", 16), - value=1024, - children={}, + }, n_allocations=1, thread_id="0x1", interesting=True, - group=[ - Frame( - location=("parent_two", "fun.py", 10), + import_system=False, + ), + ("parent_two", "fun.py", 10): Frame( + location=("parent_two", "fun.py", 10), + value=1024, + children={ + ("sibling", "fun.py", 16): Frame( + location=("sibling", "fun.py", 16), value=1024, children={}, n_allocations=1, thread_id="0x1", interesting=True, - group=[], + import_system=False, ) - ], + }, + n_allocations=1, + thread_id="0x1", + interesting=True, + import_system=False, ), }, n_allocations=2, thread_id="0x1", interesting=True, - group=[], + import_system=False, ) }, n_allocations=2, thread_id="", interesting=True, - group=[], + import_system=False, ) def test_works_with_recursive_calls(self): @@ -644,74 +431,99 @@ def test_works_with_recursive_calls(self): value=1024, children={ ("main", "recursive.py", 5): Frame( - location=("one", "recursive.py", 9), + location=("main", "recursive.py", 5), value=1024, - children={}, - n_allocations=1, - thread_id="0x1", - interesting=True, - group=[ - Frame( - location=("two", "recursive.py", 20), - value=1024, - children={}, - n_allocations=1, - thread_id="0x1", - interesting=True, - group=[], - ), - Frame( - location=("one", "recursive.py", 10), - value=1024, - children={}, - n_allocations=1, - thread_id="0x1", - interesting=True, - group=[], - ), - Frame( - location=("two", "recursive.py", 20), - value=1024, - children={}, - n_allocations=1, - thread_id="0x1", - interesting=True, - group=[], - ), - Frame( - location=("one", "recursive.py", 10), - value=1024, - children={}, - n_allocations=1, - thread_id="0x1", - interesting=True, - group=[], - ), - Frame( + children={ + ("two", "recursive.py", 20): Frame( location=("two", "recursive.py", 20), value=1024, - children={}, - n_allocations=1, - thread_id="0x1", - interesting=True, - group=[], - ), - Frame( - location=("main", "recursive.py", 5), - value=1024, - children={}, + children={ + ("one", "recursive.py", 10): Frame( + location=("one", "recursive.py", 10), + value=1024, + children={ + ("two", "recursive.py", 20): Frame( + location=("two", "recursive.py", 20), + value=1024, + children={ + ("one", "recursive.py", 10): Frame( + location=( + "one", + "recursive.py", + 10, + ), + value=1024, + children={ + ( + "two", + "recursive.py", + 20, + ): Frame( + location=( + "two", + "recursive.py", + 20, + ), + value=1024, + children={ + ( + "one", + "recursive.py", + 9, + ): Frame( + location=( + "one", + "recursive.py", + 9, + ), + value=1024, + children={}, + n_allocations=1, + thread_id="0x1", + interesting=True, + import_system=False, + ) + }, + n_allocations=1, + thread_id="0x1", + interesting=True, + import_system=False, + ) + }, + n_allocations=1, + thread_id="0x1", + interesting=True, + import_system=False, + ) + }, + n_allocations=1, + thread_id="0x1", + interesting=True, + import_system=False, + ) + }, + n_allocations=1, + thread_id="0x1", + interesting=True, + import_system=False, + ) + }, n_allocations=1, thread_id="0x1", interesting=True, - group=[], - ), - ], + import_system=False, + ) + }, + n_allocations=1, + thread_id="0x1", + interesting=True, + import_system=False, ) }, n_allocations=1, thread_id="", interesting=True, - group=[], + import_system=False, ) def test_works_with_multiple_top_level_nodes(self): @@ -754,66 +566,68 @@ def test_works_with_multiple_top_level_nodes(self): value=2048, children={ ("foo2", "/src/lel.py", 12): Frame( - location=("baz2", "/src/lel.py", 18), + location=("foo2", "/src/lel.py", 12), value=1024, - children={}, - n_allocations=1, - thread_id="0x1", - interesting=True, - group=[ - Frame( + children={ + ("bar2", "/src/lel.py", 15): Frame( location=("bar2", "/src/lel.py", 15), value=1024, - children={}, - n_allocations=1, - thread_id="0x1", - interesting=True, - group=[], - ), - Frame( - location=("foo2", "/src/lel.py", 12), - value=1024, - children={}, + children={ + ("baz2", "/src/lel.py", 18): Frame( + location=("baz2", "/src/lel.py", 18), + value=1024, + children={}, + n_allocations=1, + thread_id="0x1", + interesting=True, + import_system=False, + ) + }, n_allocations=1, thread_id="0x1", interesting=True, - group=[], - ), - ], - ), - ("foo1", "/src/lel.py", 2): Frame( - location=("baz1", "/src/lel.py", 8), - value=1024, - children={}, + import_system=False, + ) + }, n_allocations=1, thread_id="0x1", interesting=True, - group=[ - Frame( - location=("bar1", "/src/lel.py", 5), - value=1024, - children={}, - n_allocations=1, - thread_id="0x1", - interesting=True, - group=[], - ), - Frame( - location=("foo1", "/src/lel.py", 2), + import_system=False, + ), + ("foo1", "/src/lel.py", 2): Frame( + location=("foo1", "/src/lel.py", 2), + value=1024, + children={ + ("bar1", "/src/lel.py", 5): Frame( + location=("bar1", "/src/lel.py", 5), value=1024, - children={}, + children={ + ("baz1", "/src/lel.py", 8): Frame( + location=("baz1", "/src/lel.py", 8), + value=1024, + children={}, + n_allocations=1, + thread_id="0x1", + interesting=True, + import_system=False, + ) + }, n_allocations=1, thread_id="0x1", interesting=True, - group=[], - ), - ], + import_system=False, + ) + }, + n_allocations=1, + thread_id="0x1", + interesting=True, + import_system=False, ), }, n_allocations=2, thread_id="", interesting=True, - group=[], + import_system=False, ) def test_works_with_split_threads(self): @@ -856,38 +670,39 @@ def test_works_with_split_threads(self): value=2048, children={ ("foo2", "/src/lel.py", 12): Frame( - location=("baz2", "/src/lel.py", 18), + location=("foo2", "/src/lel.py", 12), value=2048, - children={}, - n_allocations=2, - thread_id="0x2", - interesting=True, - group=[ - Frame( + children={ + ("bar2", "/src/lel.py", 15): Frame( location=("bar2", "/src/lel.py", 15), value=2048, - children={}, - n_allocations=2, - thread_id="0x2", - interesting=True, - group=[], - ), - Frame( - location=("foo2", "/src/lel.py", 12), - value=2048, - children={}, + children={ + ("baz2", "/src/lel.py", 18): Frame( + location=("baz2", "/src/lel.py", 18), + value=2048, + children={}, + n_allocations=2, + thread_id="0x2", + interesting=True, + import_system=False, + ) + }, n_allocations=2, thread_id="0x2", interesting=True, - group=[], - ), - ], + import_system=False, + ) + }, + n_allocations=2, + thread_id="0x2", + interesting=True, + import_system=False, ) }, n_allocations=2, thread_id="", interesting=True, - group=[], + import_system=False, ) def test_works_with_merged_threads(self): @@ -930,38 +745,39 @@ def test_works_with_merged_threads(self): value=2048, children={ ("foo2", "/src/lel.py", 12): Frame( - location=("baz2", "/src/lel.py", 18), + location=("foo2", "/src/lel.py", 12), value=2048, - children={}, - n_allocations=2, - thread_id="merged thread", - interesting=True, - group=[ - Frame( + children={ + ("bar2", "/src/lel.py", 15): Frame( location=("bar2", "/src/lel.py", 15), value=2048, - children={}, - n_allocations=2, - thread_id="merged thread", - interesting=True, - group=[], - ), - Frame( - location=("foo2", "/src/lel.py", 12), - value=2048, - children={}, + children={ + ("baz2", "/src/lel.py", 18): Frame( + location=("baz2", "/src/lel.py", 18), + value=2048, + children={}, + n_allocations=2, + thread_id="merged thread", + interesting=True, + import_system=False, + ) + }, n_allocations=2, thread_id="merged thread", interesting=True, - group=[], - ), - ], + import_system=False, + ) + }, + n_allocations=2, + thread_id="merged thread", + interesting=True, + import_system=False, ) }, n_allocations=2, thread_id="", interesting=True, - group=[], + import_system=False, ) def test_drops_cpython_frames(self): @@ -996,34 +812,74 @@ def test_drops_cpython_frames(self): value=1024, children={ ("parent", "fun.py", 8): Frame( - location=("me", "fun.py", 12), + location=("parent", "fun.py", 8), value=1024, - children={}, - n_allocations=1, - thread_id="0x1", - interesting=True, - group=[ - Frame( - location=("parent", "fun.py", 8), + children={ + ("me", "fun.py", 12): Frame( + location=("me", "fun.py", 12), value=1024, children={}, n_allocations=1, thread_id="0x1", interesting=True, - group=[], + import_system=False, ) - ], + }, + n_allocations=1, + thread_id="0x1", + interesting=True, + import_system=False, ) }, n_allocations=1, thread_id="", interesting=True, - group=[], + import_system=False, ) - def test_very_deep_call_is_limited(self): + +@dataclass(frozen=True) +class TreeElement: + label: str + children: List["TreeElement"] + allow_expand: bool + is_expanded: bool + + +def tree_to_dict(tree: TreeNode): + return TreeElement( + str(tree.label), + [tree_to_dict(child) for child in tree.children], + tree.allow_expand, + tree.is_expanded, + ) + + +class TestTreeTui: + def test_no_allocations(self): + # GIVEN + peak_allocations = [] + + reporter = TreeReporter.from_snapshot( + peak_allocations, native_traces=False, biggest_allocs=3 + ) + app = reporter.get_app() + + # WHEN + async def run_test(): + async with app.run_test() as pilot: + await pilot.pause() + return app.query_one(Tree).root + + root = async_run(run_test()) + + # THEN + assert tree_to_dict(root) == TreeElement( + label="", children=[], is_expanded=True, allow_expand=True + ) + + def test_single_chain_is_expanded(self): # GIVEN - n_frames = sys.getrecursionlimit() * 2 peak_allocations = [ MockAllocationRecord( tid=1, @@ -1032,40 +888,150 @@ def test_very_deep_call_is_limited(self): allocator=AllocatorType.MALLOC, stack_id=1, n_allocations=1, - _stack=[(f"func_{i}", "fun.py", i) for i in range(n_frames, 0, -1)], + _stack=[ + ("me", "fun.py", 12), + ("parent", "fun.py", 8), + ("grandparent", "fun.py", 4), + ], ), ] + reporter = TreeReporter.from_snapshot( + peak_allocations, native_traces=False, biggest_allocs=3 + ) + app = reporter.get_app() + # WHEN - reporter = TreeReporter.from_snapshot(peak_allocations, native_traces=False) + async def run_test(): + async with app.run_test() as pilot: + await pilot.pause() + return app.query_one(Tree).root - # THEN - assert reporter.data.location == ("", "", 0) - assert len(reporter.data.children) == 1 - (branch,) = reporter.data.children.values() - collapsed_nodes = branch.group - assert len(collapsed_nodes) == MAX_STACKS + 1 - for index, frame in enumerate(reversed(collapsed_nodes), start=1): - assert frame.location == ( - f"func_{index}", - "fun.py", - index, - ) + root = async_run(run_test()) + # THEN + assert tree_to_dict(root) == TreeElement( + label="📂 1.000KB (100.00 %) ", + children=[ + TreeElement( + label="📂 1.000KB (100.00 %) grandparent fun.py:4", + children=[ + TreeElement( + label="📂 1.000KB (100.00 %) parent fun.py:8", + children=[ + TreeElement( + label="📄 1.000KB (100.00 %) me fun.py:12", + children=[], + allow_expand=False, + is_expanded=True, + ) + ], + allow_expand=True, + is_expanded=True, + ) + ], + allow_expand=True, + is_expanded=True, + ) + ], + allow_expand=True, + is_expanded=True, + ) -class TestRenderFrame: - def test_render_no_data(self): + def test_only_biggest_chain_is_expanded(self): # GIVEN - reporter = TreeReporter.from_snapshot([], native_traces=False) - output = StringIO() + peak_allocations = [ + MockAllocationRecord( + tid=1, + address=0x1000000, + size=1024, + allocator=AllocatorType.MALLOC, + stack_id=1, + n_allocations=1, + _stack=[ + ("me", "fun.py", 12), + ("parent", "fun.py", 8), + ("grandparent", "fun.py", 4), + ], + ), + MockAllocationRecord( + tid=1, + address=0x1000000, + size=1024 * 10, + allocator=AllocatorType.MALLOC, + stack_id=1, + n_allocations=1, + _stack=[ + ("me2", "fun2.py", 12), + ("parent2", "fun2.py", 8), + ("grandparent2", "fun2.py", 4), + ], + ), + ] + + reporter = TreeReporter.from_snapshot( + peak_allocations, native_traces=False, biggest_allocs=3 + ) + app = reporter.get_app() # WHEN - reporter.render(file=output) + async def run_test(): + async with app.run_test() as pilot: + await pilot.pause() + return app.query_one(Tree).root + + root = async_run(run_test()) # THEN - assert output.getvalue().strip() == "" + assert tree_to_dict(root) == TreeElement( + label="📂 11.000KB (100.00 %) ", + children=[ + TreeElement( + label="📂 10.000KB (90.91 %) grandparent2 fun2.py:4", + children=[ + TreeElement( + label="📂 10.000KB (90.91 %) parent2 fun2.py:8", + children=[ + TreeElement( + label="📄 10.000KB (90.91 %) me2 fun2.py:12", + children=[], + allow_expand=False, + is_expanded=True, + ) + ], + allow_expand=True, + is_expanded=True, + ) + ], + allow_expand=True, + is_expanded=True, + ), + TreeElement( + label="📂 1.000KB (9.09 %) grandparent fun.py:4", + children=[ + TreeElement( + label="📂 1.000KB (9.09 %) parent fun.py:8", + children=[ + TreeElement( + label="📄 1.000KB (9.09 %) me fun.py:12", + children=[], + allow_expand=False, + is_expanded=False, + ) + ], + allow_expand=True, + is_expanded=False, + ) + ], + allow_expand=True, + is_expanded=False, + ), + ], + allow_expand=True, + is_expanded=True, + ) - def test_render_one_allocation(self): + def test_show_uninteresting_system(self): # GIVEN peak_allocations = [ MockAllocationRecord( @@ -1076,27 +1042,183 @@ def test_render_one_allocation(self): stack_id=1, n_allocations=1, _stack=[ - ("me", "fun.py", 12), - ("parent", "fun.py", 8), - ("grandparent", "fun.py", 4), + ("me", "foo.py", 12), + ("parent", "runpy.py", 8), + ("grandparent", "runpy.py", 4), + ], + ), + MockAllocationRecord( + tid=1, + address=0x1000000, + size=1024 * 10, + allocator=AllocatorType.MALLOC, + stack_id=1, + n_allocations=1, + _stack=[ + ("me2", "fun2.py", 12), + ("parent2", "fun2.py", 8), + ("grandparent2", "fun2.py", 4), ], ), ] - reporter = TreeReporter.from_snapshot(peak_allocations, native_traces=False) - output = StringIO() + + reporter = TreeReporter.from_snapshot( + peak_allocations, native_traces=False, biggest_allocs=3 + ) + app = reporter.get_app() # WHEN - reporter.render(file=output) + async def run_test(): + async with app.run_test() as pilot: + await pilot.pause() + tree = app.query_one(Tree) + first_root = tree.root + await pilot.press("u") + await pilot.pause() + tree = app.query_one(Tree) + second_root = tree.root + return first_root, second_root + + first_root, second_root = async_run(run_test()) # THEN - expected = [ - "📂 1.000KB (100.00 %) ", - "└── [[2 frames hidden in 1 file(s)]]", - " └── 📄 1.000KB (100.00 %) me fun.py:12", + assert tree_to_dict(first_root) == TreeElement( + label="📂 11.000KB (100.00 %) ", + children=[ + TreeElement( + label="📂 10.000KB (90.91 %) grandparent2 fun2.py:4", + children=[ + TreeElement( + label="📂 10.000KB (90.91 %) parent2 fun2.py:8", + children=[ + TreeElement( + label="📄 10.000KB (90.91 %) me2 fun2.py:12", + children=[], + allow_expand=False, + is_expanded=True, + ) + ], + allow_expand=True, + is_expanded=True, + ) + ], + allow_expand=True, + is_expanded=True, + ), + TreeElement( + label="📄 1.000KB (9.09 %) me foo.py:12", + children=[], + allow_expand=False, + is_expanded=False, + ), + ], + allow_expand=True, + is_expanded=True, + ) + assert tree_to_dict(second_root) == TreeElement( + label="📂 11.000KB (100.00 %) ", + children=[ + TreeElement( + label="📂 10.000KB (90.91 %) grandparent2 fun2.py:4", + children=[ + TreeElement( + label="📂 10.000KB (90.91 %) parent2 fun2.py:8", + children=[ + TreeElement( + label="📄 10.000KB (90.91 %) me2 fun2.py:12", + children=[], + allow_expand=False, + is_expanded=True, + ) + ], + allow_expand=True, + is_expanded=True, + ) + ], + allow_expand=True, + is_expanded=True, + ), + TreeElement( + label="📂 1.000KB (9.09 %) grandparent runpy.py:4", + children=[ + TreeElement( + label="📂 1.000KB (9.09 %) parent runpy.py:8", + children=[ + TreeElement( + label="📄 1.000KB (9.09 %) me foo.py:12", + children=[], + allow_expand=False, + is_expanded=False, + ) + ], + allow_expand=True, + is_expanded=False, + ) + ], + allow_expand=True, + is_expanded=False, + ), + ], + allow_expand=True, + is_expanded=True, + ) + + def test_show_uninteresting_idempotency(self): + # GIVEN + peak_allocations = [ + MockAllocationRecord( + tid=1, + address=0x1000000, + size=1024, + allocator=AllocatorType.MALLOC, + stack_id=1, + n_allocations=1, + _stack=[ + ("me", "foo.py", 12), + ("parent", "runpy.py", 8), + ("grandparent", "runpy.py", 4), + ], + ), + MockAllocationRecord( + tid=1, + address=0x1000000, + size=1024 * 10, + allocator=AllocatorType.MALLOC, + stack_id=1, + n_allocations=1, + _stack=[ + ("me2", "fun2.py", 12), + ("parent2", "fun2.py", 8), + ("grandparent2", "fun2.py", 4), + ], + ), ] - assert [line.rstrip() for line in output.getvalue().splitlines()] == expected - def test_render_multiple_allocations_in_same_branch(self): + reporter = TreeReporter.from_snapshot( + peak_allocations, native_traces=False, biggest_allocs=3 + ) + app = reporter.get_app() + + # WHEN + async def run_test(): + async with app.run_test() as pilot: + await pilot.pause() + tree = app.query_one(Tree) + first_root = tree.root + await pilot.press("i") + await pilot.pause() + await pilot.press("i") + await pilot.pause() + tree = app.query_one(Tree) + second_root = tree.root + return first_root, second_root + + first_root, second_root = async_run(run_test()) + + # THEN + assert tree_to_dict(first_root) == tree_to_dict(second_root) + + def test_uninteresting_leaves(self): # GIVEN peak_allocations = [ MockAllocationRecord( @@ -1107,11 +1229,46 @@ def test_render_multiple_allocations_in_same_branch(self): stack_id=1, n_allocations=1, _stack=[ - ("me", "fun.py", 12), - ("parent", "fun.py", 8), - ("grandparent", "fun.py", 4), + ("parent", "runpy.py", 8), + ("grandparent", "runpy.py", 4), + ("me", "foo.py", 12), ], ), + ] + + reporter = TreeReporter.from_snapshot( + peak_allocations, native_traces=False, biggest_allocs=3 + ) + app = reporter.get_app() + + # WHEN + async def run_test(): + async with app.run_test() as pilot: + await pilot.pause() + tree = app.query_one(Tree) + return tree.root + + root = async_run(run_test()) + + # THEN + + assert tree_to_dict(root) == TreeElement( + label="📂 1.000KB (100.00 %) ", + children=[ + TreeElement( + label="📂 1.000KB (100.00 %) me foo.py:12", + children=[], + allow_expand=False, + is_expanded=True, + ) + ], + allow_expand=True, + is_expanded=True, + ) + + def test_hide_import_system(self): + # GIVEN + peak_allocations = [ MockAllocationRecord( tid=1, address=0x1000000, @@ -1120,29 +1277,157 @@ def test_render_multiple_allocations_in_same_branch(self): stack_id=1, n_allocations=1, _stack=[ - ("sibling", "fun.py", 16), - ("parent", "fun.py", 8), - ("grandparent", "fun.py", 4), + ("me", "", 12), + ("parent", "", 8), + ("grandparent", "", 4), + ], + ), + MockAllocationRecord( + tid=1, + address=0x1000000, + size=1024 * 10, + allocator=AllocatorType.MALLOC, + stack_id=1, + n_allocations=1, + _stack=[ + ("me2", "fun2.py", 12), + ("parent2", "fun2.py", 8), + ("grandparent2", "fun2.py", 4), ], ), ] - reporter = TreeReporter.from_snapshot(peak_allocations, native_traces=False) - output = StringIO() + + reporter = TreeReporter.from_snapshot( + peak_allocations, native_traces=False, biggest_allocs=3 + ) + app = reporter.get_app() # WHEN - reporter.render(file=output) + async def run_test(): + async with app.run_test() as pilot: + await pilot.pause() + tree = app.query_one(Tree) + first_root = tree.root + await pilot.press("i") + await pilot.pause() + tree = app.query_one(Tree) + second_root = tree.root + return first_root, second_root + + first_root, second_root = async_run(run_test()) # THEN - expected = [ - "📂 2.000KB (100.00 %) ", - "└── [[1 frames hidden in 1 file(s)]]", - " └── 📂 2.000KB (100.00 %) parent fun.py:8", - " ├── 📄 1.000KB (50.00 %) me fun.py:12", - " └── 📄 1.000KB (50.00 %) sibling fun.py:16", + assert tree_to_dict(first_root) == TreeElement( + label="📂 11.000KB (100.00 %) ", + children=[ + TreeElement( + label="📂 10.000KB (90.91 %) grandparent2 fun2.py:4", + children=[ + TreeElement( + label="📂 10.000KB (90.91 %) parent2 fun2.py:8", + children=[ + TreeElement( + label="📄 10.000KB (90.91 %) me2 fun2.py:12", + children=[], + allow_expand=False, + is_expanded=True, + ) + ], + allow_expand=True, + is_expanded=True, + ) + ], + allow_expand=True, + is_expanded=True, + ) + ], + allow_expand=True, + is_expanded=True, + ) + assert tree_to_dict(second_root) == TreeElement( + label="📂 11.000KB (100.00 %) ", + children=[ + TreeElement( + label="📂 10.000KB (90.91 %) grandparent2 fun2.py:4", + children=[ + TreeElement( + label="📂 10.000KB (90.91 %) parent2 fun2.py:8", + children=[ + TreeElement( + label="📄 10.000KB (90.91 %) me2 fun2.py:12", + children=[], + allow_expand=False, + is_expanded=True, + ) + ], + allow_expand=True, + is_expanded=True, + ) + ], + allow_expand=True, + is_expanded=True, + ) + ], + allow_expand=True, + is_expanded=True, + ) + + def test_hide_import_system_idempotency(self): + # GIVEN + peak_allocations = [ + MockAllocationRecord( + tid=1, + address=0x1000000, + size=1024, + allocator=AllocatorType.MALLOC, + stack_id=1, + n_allocations=1, + _stack=[ + ("me", "", 12), + ("parent", "", 8), + ("grandparent", "", 4), + ], + ), + MockAllocationRecord( + tid=1, + address=0x1000000, + size=1024 * 10, + allocator=AllocatorType.MALLOC, + stack_id=1, + n_allocations=1, + _stack=[ + ("me2", "fun2.py", 12), + ("parent2", "fun2.py", 8), + ("grandparent2", "fun2.py", 4), + ], + ), ] - assert [line.rstrip() for line in output.getvalue().splitlines()] == expected - def test_render_multiple_allocations_in_diferent_branches(self): + reporter = TreeReporter.from_snapshot( + peak_allocations, native_traces=False, biggest_allocs=3 + ) + app = reporter.get_app() + + # WHEN + async def run_test(): + async with app.run_test() as pilot: + await pilot.pause() + tree = app.query_one(Tree) + first_root = tree.root + await pilot.press("i") + await pilot.pause() + await pilot.press("i") + await pilot.pause() + tree = app.query_one(Tree) + second_root = tree.root + return first_root, second_root + + first_root, second_root = async_run(run_test()) + + # THEN + assert tree_to_dict(first_root) == tree_to_dict(second_root) + + def test_expand_linear_chain(self): # GIVEN peak_allocations = [ MockAllocationRecord( @@ -1158,6 +1443,92 @@ def test_render_multiple_allocations_in_diferent_branches(self): ("grandparent", "fun.py", 4), ], ), + MockAllocationRecord( + tid=1, + address=0x1000000, + size=1024 * 10, + allocator=AllocatorType.MALLOC, + stack_id=1, + n_allocations=1, + _stack=[ + ("me2", "fun2.py", 12), + ("parent2", "fun2.py", 8), + ("grandparent2", "fun2.py", 4), + ], + ), + ] + + reporter = TreeReporter.from_snapshot( + peak_allocations, native_traces=False, biggest_allocs=3 + ) + app = reporter.get_app() + + # WHEN + async def run_test(): + async with app.run_test() as pilot: + await pilot.pause() + tree = app.query_one(Tree) + child = tree.root.children[1] + tree.select_node(child) + await pilot.press("e") + await pilot.pause() + return tree.root + + root = async_run(run_test()) + + # THEN + assert tree_to_dict(root) == TreeElement( + label="📂 11.000KB (100.00 %) ", + children=[ + TreeElement( + label="📂 10.000KB (90.91 %) grandparent2 fun2.py:4", + children=[ + TreeElement( + label="📂 10.000KB (90.91 %) parent2 fun2.py:8", + children=[ + TreeElement( + label="📄 10.000KB (90.91 %) me2 fun2.py:12", + children=[], + allow_expand=False, + is_expanded=True, + ) + ], + allow_expand=True, + is_expanded=True, + ) + ], + allow_expand=True, + is_expanded=True, + ), + TreeElement( + label="📂 1.000KB (9.09 %) grandparent fun.py:4", + children=[ + TreeElement( + label="📂 1.000KB (9.09 %) parent fun.py:8", + children=[ + TreeElement( + label="📄 1.000KB (9.09 %) me fun.py:12", + children=[], + allow_expand=False, + is_expanded=True, + ) + ], + allow_expand=True, + is_expanded=True, + ) + ], + allow_expand=True, + is_expanded=True, + ), + ], + allow_expand=True, + is_expanded=True, + ) + + def test_very_deep_call_is_limited(self): + # GIVEN + n_frames = MAX_STACKS + 50 + peak_allocations = [ MockAllocationRecord( tid=1, address=0x1000000, @@ -1165,31 +1536,138 @@ def test_render_multiple_allocations_in_diferent_branches(self): allocator=AllocatorType.MALLOC, stack_id=1, n_allocations=1, + _stack=[(f"func_{i}", "fun.py", i) for i in range(n_frames, 0, -1)], + ), + ] + + reporter = TreeReporter.from_snapshot(peak_allocations, native_traces=False) + app = reporter.get_app() + + # WHEN + async def run_test(): + async with app.run_test() as pilot: + await pilot.pause() + return app.query_one(Tree).root + + root = async_run(run_test()) + + assert str(root.label) == "📂 1.000KB (100.00 %) " + assert len(root.children) == 1 + current_node = root.children[0] + for i in range(1, MAX_STACKS + 2): + assert f"func_{i}" in str(current_node.label) + assert len(current_node.children) == 1 + current_node = current_node.children[0] + assert not current_node.children + + def test_allocations_of_different_size_classes(self): + # GIVEN + peak_allocations = [ + MockAllocationRecord( + tid=1, + address=0x1000000, + size=65, + allocator=AllocatorType.MALLOC, + stack_id=1, + n_allocations=1, + _stack=[("func", "fun.py", 1)], + ), + MockAllocationRecord( + tid=1, + address=0x1000000, + size=34, + allocator=AllocatorType.MALLOC, + stack_id=1, + n_allocations=1, + _stack=[("func2", "fun.py", 2), ("func", "fun.py", 1)], + ), + MockAllocationRecord( + tid=1, + address=0x1000000, + size=1, + allocator=AllocatorType.MALLOC, + stack_id=1, + n_allocations=1, _stack=[ - ("me2", "fun.py", 16), - ("parent2", "fun.py", 8), - ("grandparent2", "fun.py", 4), + ("func3", "fun.py", 3), + ("func2", "fun.py", 2), + ("func", "fun.py", 1), ], ), ] + reporter = TreeReporter.from_snapshot(peak_allocations, native_traces=False) - output = StringIO() + app = reporter.get_app() # WHEN - reporter.render(file=output) + async def run_test(): + async with app.run_test() as pilot: + await pilot.pause() + return app.query_one(Tree).root + + root = async_run(run_test()) # THEN - expected = [ - "📂 2.000KB (100.00 %) ", - "├── [[2 frames hidden in 1 file(s)]]", - "│ └── 📄 1.000KB (50.00 %) me fun.py:12", - "└── [[2 frames hidden in 1 file(s)]]", - " └── 📄 1.000KB (50.00 %) me2 fun.py:16", - ] - assert [line.rstrip() for line in output.getvalue().splitlines()] == expected + assert root.label.spans[0].style == "red" + first_child = root.children[0] + assert first_child.label.spans[0].style == "red" + second_child = first_child.children[0] + assert second_child.label.spans[0].style == "yellow" + third_child = second_child.children[0] + assert third_child.label.spans[0].style == "bright_green" + assert not third_child.children + + def test_render_runs_the_app(self): + # GIVEN + with patch("memray.reporters.tree.TreeReporter.get_app") as get_app: + reporter = TreeReporter.from_snapshot([], native_traces=False) + # WHEN + reporter.render() - def test_render_multiple_allocations_with_no_single_childs(self): + # THEN + get_app.return_value.run.assert_called() + + +@pytest.fixture +def compare(monkeypatch, tmp_path, snap_compare): + def compare_impl( + allocations: Iterator[AllocationRecord], + press: Iterable[str] = (), + terminal_size: Tuple[int, int] = (120, 60), + run_before: Optional[Callable[[Pilot], Optional[Awaitable[None]]]] = None, + native: bool = False, + ): + reporter = TreeReporter.from_snapshot(allocations, native_traces=native) + app = reporter.get_app() + tmp_main = tmp_path / "main.py" + app_global = "_CURRENT_APP_" + with monkeypatch.context() as app_patch: + app_patch.setitem(globals(), app_global, app) + tmp_main.write_text(f"from {__name__} import {app_global} as app") + return snap_compare( + str(tmp_main), + press=press, + terminal_size=terminal_size, + run_before=run_before, + ) + + yield compare_impl + + +class TestTUILooks: + def test_basic(self, compare): # GIVEN + code = dedent( + """\ + import itertools + def generate_primes(): + numbers = itertools.count(2) + while True: + prime = next(numbers) + yield prime + numbers = filter(lambda x, prime=prime: x % prime, numbers) + """ + ) peak_allocations = [ MockAllocationRecord( tid=1, @@ -1201,8 +1679,30 @@ def test_render_multiple_allocations_with_no_single_childs(self): _stack=[ ("me", "fun.py", 12), ("parent", "fun.py", 8), + ("grandparent", "fun.py", 4), ], ), + ] + + # WHEN / THEN + with patch("linecache.getlines") as getlines: + getlines.return_value = code.splitlines() + assert compare(peak_allocations, press=[]) + + def test_basic_node_selected_not_leaf(self, compare): + # GIVEN + code = dedent( + """\ + import itertools + def generate_primes(): + numbers = itertools.count(2) + while True: + prime = next(numbers) + yield prime + numbers = filter(lambda x, prime=prime: x % prime, numbers) + """ + ) + peak_allocations = [ MockAllocationRecord( tid=1, address=0x1000000, @@ -1211,10 +1711,32 @@ def test_render_multiple_allocations_with_no_single_childs(self): stack_id=1, n_allocations=1, _stack=[ - ("me2", "fun.py", 16), + ("me", "fun.py", 12), ("parent", "fun.py", 8), + ("grandparent", "fun.py", 4), ], ), + ] + + # WHEN / THEN + with patch("linecache.getlines") as getlines: + getlines.return_value = code.splitlines() + assert compare(peak_allocations, press=[*["down"] * 2]) + + def test_basic_node_selected_leaf(self, compare): + # GIVEN + code = dedent( + """\ + import itertools + def generate_primes(): + numbers = itertools.count(2) + while True: + prime = next(numbers) + yield prime + numbers = filter(lambda x, prime=prime: x % prime, numbers) + """ + ) + peak_allocations = [ MockAllocationRecord( tid=1, address=0x1000000, @@ -1223,10 +1745,32 @@ def test_render_multiple_allocations_with_no_single_childs(self): stack_id=1, n_allocations=1, _stack=[ - ("me", "fun.py", 16), - ("parent2", "fun.py", 8), + ("me", "fun.py", 12), + ("parent", "fun.py", 8), + ("grandparent", "fun.py", 4), ], ), + ] + + # WHEN / THEN + with patch("linecache.getlines") as getlines: + getlines.return_value = code.splitlines() + assert compare(peak_allocations, press=[*["down"] * 3]) + + def test_two_chains(self, compare): + # GIVEN + code = dedent( + """\ + import itertools + def generate_primes(): + numbers = itertools.count(2) + while True: + prime = next(numbers) + yield prime + numbers = filter(lambda x, prime=prime: x % prime, numbers) + """ + ) + peak_allocations = [ MockAllocationRecord( tid=1, address=0x1000000, @@ -1235,31 +1779,44 @@ def test_render_multiple_allocations_with_no_single_childs(self): stack_id=1, n_allocations=1, _stack=[ - ("me2", "fun.py", 16), - ("parent2", "fun.py", 8), + ("me", "fun.py", 12), + ("parent", "fun.py", 8), + ("grandparent", "fun.py", 4), + ], + ), + MockAllocationRecord( + tid=1, + address=0x1000000, + size=1024 * 10, + allocator=AllocatorType.MALLOC, + stack_id=1, + n_allocations=1, + _stack=[ + ("me2", "fun2.py", 12), + ("parent2", "fun2.py", 8), + ("grandparent2", "fun2.py", 4), ], ), ] - reporter = TreeReporter.from_snapshot(peak_allocations, native_traces=False) - output = StringIO() - - # WHEN - reporter.render(file=output) - # THEN - expected = [ - "📂 4.000KB (100.00 %) ", - "├── 📂 2.000KB (50.00 %) parent fun.py:8", - "│ ├── 📄 1.000KB (25.00 %) me fun.py:12", - "│ └── 📄 1.000KB (25.00 %) me2 fun.py:16", - "└── 📂 2.000KB (50.00 %) parent2 fun.py:8", - " ├── 📄 1.000KB (25.00 %) me fun.py:16", - " └── 📄 1.000KB (25.00 %) me2 fun.py:16", - ] - assert [line.rstrip() for line in output.getvalue().splitlines()] == expected + # WHEN / THEN + with patch("linecache.getlines") as getlines: + getlines.return_value = code.splitlines() + assert compare(peak_allocations, press=[]) - def test_render_long_chain(self): + def test_two_chains_after_expanding_second(self, compare): # GIVEN + code = dedent( + """\ + import itertools + def generate_primes(): + numbers = itertools.count(2) + while True: + prime = next(numbers) + yield prime + numbers = filter(lambda x, prime=prime: x % prime, numbers) + """ + ) peak_allocations = [ MockAllocationRecord( tid=1, @@ -1270,31 +1827,109 @@ def test_render_long_chain(self): n_allocations=1, _stack=[ ("a", "fun.py", 1), - ("b", "fun.py", 9), - ("c", "fun.py", 10), - ("d", "fun.py", 11), - ("e", "fun.py", 11), - ("f", "fun.py", 11), + ("b", "fun.py", 2), + ("c", "fun.py", 3), + ("d", "fun.py", 4), + ("e", "fun.py", 5), + ], + ), + MockAllocationRecord( + tid=1, + address=0x1000000, + size=1024 * 10, + allocator=AllocatorType.MALLOC, + stack_id=1, + n_allocations=1, + _stack=[ + ("me2", "fun2.py", 12), + ("parent2", "fun2.py", 8), + ("grandparent2", "fun2.py", 4), ], ), ] - reporter = TreeReporter.from_snapshot(peak_allocations, native_traces=False) - output = StringIO() - # WHEN - reporter.render(file=output) + # WHEN / THEN + with patch("linecache.getlines") as getlines: + getlines.return_value = code.splitlines() + assert compare(peak_allocations, press=[*["down"] * 4, "e"]) - # THEN - expected = [ - "📂 1.000KB (100.00 %) ", - "└── [[5 frames hidden in 1 file(s)]]", - " └── 📄 1.000KB (100.00 %) a fun.py:1", + def test_hide_import_system(self, compare): + # GIVEN + code = dedent( + """\ + import itertools + def generate_primes(): + numbers = itertools.count(2) + while True: + prime = next(numbers) + yield prime + numbers = filter(lambda x, prime=prime: x % prime, numbers) + """ + ) + peak_allocations = [ + MockAllocationRecord( + tid=1, + address=0x1000000, + size=1024, + allocator=AllocatorType.MALLOC, + stack_id=1, + n_allocations=1, + _stack=[ + ("a0", "some other frame", 4), + ("a", "", 1), + ("b", "", 2), + ("c", "", 3), + ("d", "", 4), + ("e", "", 5), + ], + ), + MockAllocationRecord( + tid=1, + address=0x1000000, + size=1024 * 10, + allocator=AllocatorType.MALLOC, + stack_id=1, + n_allocations=1, + _stack=[ + ("me2", "fun2.py", 12), + ("parent2", "fun2.py", 8), + ("grandparent2", "fun2.py", 4), + ], + ), ] - assert [line.rstrip() for line in output.getvalue().splitlines()] == expected - def test_render_long_chain_with_branch_at_the_end(self): + # WHEN / THEN + with patch("linecache.getlines") as getlines: + getlines.return_value = code.splitlines() + assert compare(peak_allocations, press=["i"]) + + def test_show_uninteresting(self, compare): # GIVEN + code = dedent( + """\ + import itertools + def generate_primes(): + numbers = itertools.count(2) + while True: + prime = next(numbers) + yield prime + numbers = filter(lambda x, prime=prime: x % prime, numbers) + """ + ) peak_allocations = [ + MockAllocationRecord( + tid=1, + address=0x1000000, + size=1024 * 10, + allocator=AllocatorType.MALLOC, + stack_id=1, + n_allocations=1, + _stack=[ + ("me2", "fun2.py", 12), + ("parent2", "fun2.py", 8), + ("grandparent2", "fun2.py", 4), + ], + ), MockAllocationRecord( tid=1, address=0x1000000, @@ -1303,12 +1938,50 @@ def test_render_long_chain_with_branch_at_the_end(self): stack_id=1, n_allocations=1, _stack=[ - ("a1", "fun.py", 1), - ("b", "fun2.py", 9), - ("c", "fun3.py", 10), - ("d", "fun4.py", 11), - ("e", "fun5.py", 11), - ("f", "fun6.py", 11), + ("a0", "some other frame", 4), + ("a", "runpy.py", 1), + ("b", "runpy.py", 2), + ("c", "runpy.py", 3), + ("d", "runpy.py", 4), + ("e", "runpy.py", 5), + ], + ), + ] + + # WHEN / THEN + with patch("linecache.getlines") as getlines: + getlines.return_value = code.splitlines() + assert compare(peak_allocations, press=["u"]) + + def test_show_uninteresting_and_hide_import_system(self, compare): + # GIVEN + code = dedent( + """\ + import itertools + def generate_primes(): + numbers = itertools.count(2) + while True: + prime = next(numbers) + yield prime + numbers = filter(lambda x, prime=prime: x % prime, numbers) + """ + ) + peak_allocations = [ + MockAllocationRecord( + tid=1, + address=0x1000000, + size=1024 * 10, + allocator=AllocatorType.MALLOC, + stack_id=1, + n_allocations=1, + _stack=[ + ("B", "some other frame", 4), + ("d", "", 3), + ("e", "", 4), + ("A", "some other frame", 4), + ("a", "runpy.py", 1), + ("b", "runpy.py", 2), + ("c", "runpy.py", 5), ], ), MockAllocationRecord( @@ -1319,27 +1992,47 @@ def test_render_long_chain_with_branch_at_the_end(self): stack_id=1, n_allocations=1, _stack=[ - ("a2", "fun.py", 1), - ("b", "fun2.py", 9), - ("c", "fun3.py", 10), - ("d", "fun4.py", 11), - ("e", "fun5.py", 11), - ("f", "fun6.py", 11), + ("me2", "fun2.py", 12), + ("parent2", "fun2.py", 8), + ("grandparent2", "fun2.py", 4), ], ), ] - reporter = TreeReporter.from_snapshot(peak_allocations, native_traces=False) - output = StringIO() - # WHEN - reporter.render(file=output) + # WHEN / THEN + with patch("linecache.getlines") as getlines: + getlines.return_value = code.splitlines() + assert compare(peak_allocations, press=["u", "i"]) - # THEN - expected = [ - "📂 2.000KB (100.00 %) ", - "└── [[4 frames hidden in 4 file(s)]]", - " └── 📂 2.000KB (100.00 %) b fun2.py:9", - " ├── 📄 1.000KB (50.00 %) a1 fun.py:1", - " └── 📄 1.000KB (50.00 %) a2 fun.py:1", + def test_select_screen(self, tmp_path, compare): + # GIVEN + code = dedent( + """\ + import itertools + def generate_primes(): + numbers = itertools.count(2) + while True: + prime = next(numbers) + yield prime + numbers = filter(lambda x, prime=prime: x % prime, numbers) + """ + ) + peak_allocations = [ + MockAllocationRecord( + tid=1, + address=0x1000000, + size=1024, + allocator=AllocatorType.MALLOC, + stack_id=1, + n_allocations=1, + _stack=[ + ("me2", "func2.py", 4), + ("parent2", "fun2.py", 8), + ("grandparent2", "fun2.py", 4), + ], + ), ] - assert [line.rstrip() for line in output.getvalue().splitlines()] == expected + # WHEN / THEN + with patch("linecache.getlines") as getlines: + getlines.return_value = code.splitlines() + assert compare(peak_allocations, press=[*["down"] * 3]) diff --git a/tests/unit/test_tui_reporter.py b/tests/unit/test_tui_reporter.py index 0e226b6dd8..57e387a134 100644 --- a/tests/unit/test_tui_reporter.py +++ b/tests/unit/test_tui_reporter.py @@ -1,6 +1,5 @@ import asyncio import datetime -import sys from io import StringIO from typing import Awaitable from typing import Callable @@ -30,21 +29,7 @@ from memray.reporters.tui import TUIApp from memray.reporters.tui import aggregate_allocations from tests.utils import MockAllocationRecord - - -def async_run(coro): - # This technique shamelessly cribbed from Textual itself... - # `asyncio.get_event_loop()` is deprecated since Python 3.10: - asyncio_get_event_loop_is_deprecated = sys.version_info >= (3, 10, 0) - - if asyncio_get_event_loop_is_deprecated: - # N.B. This doesn't work with Python<3.10, as we end up with 2 event loops: - asyncio.run(coro) - else: - # pragma: no cover - # However, this works with Python<3.10: - event_loop = asyncio.get_event_loop() - event_loop.run_until_complete(coro) +from tests.utils import async_run class MockApp(TUIApp): diff --git a/tests/utils.py b/tests/utils.py index e5895ae53f..3e3660a5f2 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,5 +1,5 @@ """Utilities / Helpers for writing tests.""" - +import asyncio import sys from contextlib import contextmanager from dataclasses import dataclass @@ -99,3 +99,18 @@ def run_without_tracer(): finally: sys.settrace(prev_trace) sys.setprofile(prev_profile) + + +def async_run(coro): + # This technique shamelessly cribbed from Textual itself... + # `asyncio.get_event_loop()` is deprecated since Python 3.10: + asyncio_get_event_loop_is_deprecated = sys.version_info >= (3, 10, 0) + + if asyncio_get_event_loop_is_deprecated: + # N.B. This doesn't work with Python<3.10, as we end up with 2 event loops: + return asyncio.run(coro) + else: + # pragma: no cover + # However, this works with Python<3.10: + event_loop = asyncio.get_event_loop() + return event_loop.run_until_complete(coro)