Skip to content

Commit d95c3fe

Browse files
committed
Port the tree reporter to textual
Now that we are using Textual for the live mode, we can port the tree reporter to be a live Textual App. This has plenty of advantages over the static version as it offers interactive exploration of the tree, as well as the possibility of using different screens for showing detailed information about allocations such as the source and metadata. Signed-off-by: Pablo Galindo <[email protected]>
1 parent 986a17e commit d95c3fe

File tree

7 files changed

+2234
-702
lines changed

7 files changed

+2234
-702
lines changed

news/499.feature.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Port the tree reporter to be an interactive Textual App.

src/memray/reporters/tree.py

+244-76
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,38 @@
1+
import linecache
12
import sys
23
from dataclasses import dataclass
34
from dataclasses import field
5+
from dataclasses import replace
46
from typing import IO
7+
from typing import Any
8+
from typing import Callable
59
from typing import Dict
610
from typing import Iterator
7-
from typing import List
811
from typing import Optional
912
from typing import Tuple
1013

11-
import rich
12-
import rich.tree
14+
from textual import events
15+
from textual.app import App
16+
from textual.app import ComposeResult
17+
from textual.binding import Binding
18+
from textual.containers import Grid
19+
from textual.dom import DOMNode
20+
from textual.screen import ModalScreen
21+
from textual.widgets import Footer
22+
from textual.widgets import Header
23+
from textual.widgets import Label
24+
from textual.widgets import ListItem
25+
from textual.widgets import ListView
26+
from textual.widgets import TextArea
27+
from textual.widgets import Tree
28+
from textual.widgets._text_area import Edit
29+
from textual.widgets.tree import TreeNode
1330

1431
from memray import AllocationRecord
1532
from memray._memray import size_fmt
1633
from memray.reporters.frame_tools import is_cpython_internal
34+
from memray.reporters.frame_tools import is_frame_from_import_system
35+
from memray.reporters.frame_tools import is_frame_interesting
1736

1837
MAX_STACKS = int(sys.getrecursionlimit() // 2.5)
1938

@@ -24,29 +43,219 @@
2443

2544
@dataclass
2645
class Frame:
46+
"""A frame in the tree"""
47+
2748
location: StackElement
2849
value: int
2950
children: Dict[StackElement, "Frame"] = field(default_factory=dict)
3051
n_allocations: int = 0
3152
thread_id: str = ""
3253
interesting: bool = True
33-
group: List["Frame"] = field(default_factory=list)
34-
35-
def collapse_tree(self) -> "Frame":
36-
if len(self.children) == 0:
37-
return self
38-
elif len(self.children) == 1 and ROOT_NODE != self.location:
39-
[[key, child]] = self.children.items()
40-
self.children.pop(key)
41-
new_node = child.collapse_tree()
42-
new_node.group.append(self)
43-
return new_node
54+
import_system: bool = False
55+
56+
57+
class FrozenTextArea(TextArea):
58+
"""A text area that cannot be edited"""
59+
60+
def __init__(self, *args: Any, **kwargs: Any) -> None:
61+
super().__init__(*args, **kwargs)
62+
self.cursor_blink = False
63+
64+
def edit(self, edit: Edit) -> Any:
65+
self.app.pop_screen()
66+
67+
68+
class FrameDetailScreen(ModalScreen[bool]):
69+
"""A screen that displays information about a frame"""
70+
71+
def __init__(self, frame: Frame):
72+
super().__init__()
73+
self.frame = frame
74+
75+
def compose(self) -> ComposeResult:
76+
function, file, line = self.frame.location
77+
delta = 3
78+
lines = linecache.getlines(file)[line - delta : line + delta]
79+
text = FrozenTextArea(
80+
"\n".join(lines), language="python", theme="dracula", id="textarea"
81+
)
82+
text.select_line(delta + 1)
83+
text.show_line_numbers = False
84+
yield Grid(
85+
text,
86+
ListView(
87+
ListItem(Label(f":compass: Function: {function}")),
88+
ListItem(Label(f":compass: Location: {file}:{line}")),
89+
ListItem(
90+
Label(f":floppy_disk: Allocations: {self.frame.n_allocations}")
91+
),
92+
ListItem(Label(f":package: Size: {size_fmt(self.frame.value)}")),
93+
ListItem(Label(f":thread: Thread: {self.frame.thread_id}")),
94+
ListItem(Label("Press any key to go back")),
95+
),
96+
id="node",
97+
)
98+
yield Footer()
99+
100+
def on_key(self, event: events.Key) -> None:
101+
self.dismiss(True)
102+
103+
104+
class TreeApp(App[None]):
105+
BINDINGS = [
106+
Binding(key="q", action="quit", description="Quit the app"),
107+
Binding(
108+
key="s", action="show_information", description="Show node information"
109+
),
110+
Binding(key="i", action="hide_import_system", description="Hide import system"),
111+
Binding(
112+
key="e", action="expand_linear_group", description="Expand linear group"
113+
),
114+
]
115+
116+
DEFAULT_CSS = """
117+
QuitScreen {
118+
align: center middle;
119+
}
120+
121+
Label {
122+
padding: 1 3;
123+
}
124+
125+
#textarea {
126+
height: 20;
127+
}
128+
129+
#node {
130+
grid-size: 1 2;
131+
grid-gutter: 1 2;
132+
padding: 0 1;
133+
width: 80;
134+
height: 40;
135+
border: thick $background 80%;
136+
background: $surface;
137+
}
138+
"""
139+
140+
def __init__(self, data: Frame):
141+
super().__init__()
142+
self.data = data
143+
self.filter: Optional[Callable[[Frame], bool]] = None
144+
145+
def expand_bigger_nodes(self, node: TreeNode[Frame]) -> None:
146+
if not node.children:
147+
return
148+
biggest_child = max(
149+
node.children, key=lambda child: 0 if not child.data else child.data.value
150+
)
151+
biggest_child.toggle()
152+
self.expand_bigger_nodes(biggest_child)
153+
154+
def compose(self) -> ComposeResult:
155+
yield Header()
156+
tree = self.create_tree(self.data)
157+
tree.root.expand()
158+
self.expand_bigger_nodes(tree.root)
159+
yield tree
160+
yield Footer()
161+
162+
def action_expand_linear_group(self) -> None:
163+
tree = self.query_one(Tree)
164+
assert tree
165+
current_node = tree.cursor_node
166+
while current_node:
167+
current_node.toggle()
168+
if len(current_node.children) != 1:
169+
break
170+
current_node = current_node.children[0]
171+
172+
def action_show_information(self) -> None:
173+
tree: Tree[Frame] = self.query_one(Tree)
174+
if tree.cursor_node is None or tree.cursor_node.data is None:
175+
return
176+
self.push_screen(FrameDetailScreen(tree.cursor_node.data))
177+
178+
def create_tree(
179+
self,
180+
node: Frame,
181+
parent_tree: Optional[TreeNode[Frame]] = None,
182+
root_node: Optional[Tree[Frame]] = None,
183+
) -> Tree[Frame]:
184+
if node.value == 0:
185+
return Tree("<No allocations>")
186+
value = node.value
187+
root_data = root_node.root.data if root_node else node
188+
assert root_data is not None
189+
size_str = f"{size_fmt(value)} ({100 * value / root_data.value:.2f} %)"
190+
function, file, lineno = node.location
191+
icon = ":page_facing_up:" if len(node.children) == 0 else ":open_file_folder:"
192+
frame_text = (
193+
"{icon}[{info_color}] {size} "
194+
"[bold]{function}[/bold][/{info_color}] "
195+
"[dim cyan]{code_position}[/dim cyan]".format(
196+
icon=icon,
197+
size=size_str,
198+
info_color=_info_color(node, root_data),
199+
function=function,
200+
code_position=f"{file}:{lineno}" if lineno != 0 else file,
201+
)
202+
)
203+
children = tuple(node.children.values())
204+
if self.filter is not None:
205+
children = tuple(filter(self.filter, children))
206+
if root_node is None:
207+
root_node = Tree(frame_text, data=node)
208+
new_tree = root_node.root
209+
else:
210+
assert parent_tree is not None
211+
new_tree = parent_tree.add(
212+
frame_text, data=node, allow_expand=bool(len(children))
213+
)
214+
for child in children:
215+
self.create_tree(child, new_tree, root_node=root_node)
216+
return root_node
217+
218+
def action_hide_import_system(self) -> None:
219+
self.query_one(Tree).remove()
220+
if self.filter is None:
221+
222+
def _filter(node: Frame) -> bool:
223+
return not node.import_system
224+
225+
self.filter = _filter
44226
else:
45-
self.children = {
46-
location: child.collapse_tree()
47-
for location, child in self.children.items()
48-
}
49-
return self
227+
self.filter = None
228+
self.remount_tree()
229+
230+
def remount_tree(self) -> None:
231+
new_tree: Tree[Frame] = self.create_tree(self.data)
232+
self.mount(new_tree)
233+
new_tree.focus()
234+
new_tree.root.expand()
235+
self.expand_bigger_nodes(new_tree.root)
236+
237+
@property
238+
def namespace_bindings(self) -> Dict[str, Tuple[DOMNode, Binding]]:
239+
bindings = super().namespace_bindings.copy()
240+
if self.filter is not None:
241+
node, binding = bindings["i"]
242+
bindings["i"] = (
243+
node,
244+
replace(binding, description="Show import system"),
245+
)
246+
return bindings
247+
248+
249+
def _info_color(node: Frame, root_node: Frame) -> str:
250+
proportion_of_total = node.value / root_node.value
251+
if proportion_of_total > 0.6:
252+
return "red"
253+
elif proportion_of_total > 0.2:
254+
return "yellow"
255+
elif proportion_of_total > 0.05:
256+
return "green"
257+
else:
258+
return "bright_green"
50259

51260

52261
class TreeReporter:
@@ -62,7 +271,7 @@ def from_snapshot(
62271
biggest_allocs: int = 10,
63272
native_traces: bool,
64273
) -> "TreeReporter":
65-
data = Frame(location=ROOT_NODE, value=0)
274+
data = Frame(location=ROOT_NODE, value=0, import_system=False, interesting=True)
66275
for record in sorted(allocations, key=lambda alloc: alloc.size, reverse=True)[
67276
:biggest_allocs
68277
]:
@@ -79,8 +288,17 @@ def from_snapshot(
79288
for index, stack_frame in enumerate(reversed(stack)):
80289
if is_cpython_internal(stack_frame):
81290
continue
291+
is_import_system = is_frame_from_import_system(stack_frame)
292+
is_interesting = (
293+
is_frame_interesting(stack_frame) and not is_import_system
294+
)
82295
if stack_frame not in current_frame.children:
83-
node = Frame(value=0, location=stack_frame)
296+
node = Frame(
297+
value=0,
298+
location=stack_frame,
299+
import_system=is_import_system,
300+
interesting=is_interesting,
301+
)
84302
current_frame.children[stack_frame] = node
85303

86304
current_frame = current_frame.children[stack_frame]
@@ -91,64 +309,14 @@ def from_snapshot(
91309
if index > MAX_STACKS:
92310
break
93311

94-
return cls(data.collapse_tree())
312+
return cls(data)
313+
314+
def get_app(self) -> TreeApp:
315+
return TreeApp(self.data)
95316

96317
def render(
97318
self,
98319
*,
99320
file: Optional[IO[str]] = None,
100321
) -> None:
101-
tree = self.make_rich_node(node=self.data)
102-
rich.print(tree, file=file)
103-
104-
def make_rich_node(
105-
self,
106-
node: Frame,
107-
parent_tree: Optional[rich.tree.Tree] = None,
108-
root_node: Optional[Frame] = None,
109-
depth: int = 0,
110-
) -> rich.tree.Tree:
111-
if node.value == 0:
112-
return rich.tree.Tree("<No allocations>")
113-
if root_node is None:
114-
root_node = node
115-
116-
if node.group:
117-
libs = {frame.location[1] for frame in node.group}
118-
text = f"[blue][[{len(node.group)} frames hidden in {len(libs)} file(s)]][/blue]"
119-
parent_tree = (
120-
rich.tree.Tree(text) if parent_tree is None else parent_tree.add(text)
121-
)
122-
value = node.value
123-
size_str = f"{size_fmt(value)} ({100 * value / root_node.value:.2f} %)"
124-
function, file, lineno = node.location
125-
icon = ":page_facing_up:" if len(node.children) == 0 else ":open_file_folder:"
126-
frame_text = (
127-
"{icon}[{info_color}] {size} "
128-
"[bold]{function}[/bold][/{info_color}] "
129-
"[dim cyan]{code_position}[/dim cyan]".format(
130-
icon=icon,
131-
size=size_str,
132-
info_color=self._info_color(node, root_node),
133-
function=function,
134-
code_position=f"{file}:{lineno}" if lineno != 0 else file,
135-
)
136-
)
137-
if parent_tree is None:
138-
parent_tree = new_tree = rich.tree.Tree(frame_text)
139-
else:
140-
new_tree = parent_tree.add(frame_text)
141-
for child in node.children.values():
142-
self.make_rich_node(child, new_tree, depth=depth + 1, root_node=root_node)
143-
return parent_tree
144-
145-
def _info_color(self, node: Frame, root_node: Frame) -> str:
146-
proportion_of_total = node.value / root_node.value
147-
if proportion_of_total > 0.6:
148-
return "red"
149-
elif proportion_of_total > 0.2:
150-
return "yellow"
151-
elif proportion_of_total > 0.05:
152-
return "green"
153-
else:
154-
return "bright_green"
322+
self.get_app().run()

tests/integration/test_main.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -870,7 +870,7 @@ def test_tree_generated(self, tmp_path, simple_test_file):
870870
)
871871

872872
# THEN
873-
assert "frames hidden" in output
873+
assert "Biggest 10 allocations" in output
874874

875875
def test_temporary_allocations_tree(self, tmp_path, simple_test_file):
876876
# GIVEN

0 commit comments

Comments
 (0)