1
+ import linecache
1
2
import sys
2
3
from dataclasses import dataclass
3
4
from dataclasses import field
5
+ from dataclasses import replace
4
6
from typing import IO
7
+ from typing import Any
8
+ from typing import Callable
5
9
from typing import Dict
6
10
from typing import Iterator
7
- from typing import List
8
11
from typing import Optional
9
12
from typing import Tuple
10
13
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
13
30
14
31
from memray import AllocationRecord
15
32
from memray ._memray import size_fmt
16
33
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
17
36
18
37
MAX_STACKS = int (sys .getrecursionlimit () // 2.5 )
19
38
24
43
25
44
@dataclass
26
45
class Frame :
46
+ """A frame in the tree"""
47
+
27
48
location : StackElement
28
49
value : int
29
50
children : Dict [StackElement , "Frame" ] = field (default_factory = dict )
30
51
n_allocations : int = 0
31
52
thread_id : str = ""
32
53
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
44
226
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"
50
259
51
260
52
261
class TreeReporter :
@@ -62,7 +271,7 @@ def from_snapshot(
62
271
biggest_allocs : int = 10 ,
63
272
native_traces : bool ,
64
273
) -> "TreeReporter" :
65
- data = Frame (location = ROOT_NODE , value = 0 )
274
+ data = Frame (location = ROOT_NODE , value = 0 , import_system = False , interesting = True )
66
275
for record in sorted (allocations , key = lambda alloc : alloc .size , reverse = True )[
67
276
:biggest_allocs
68
277
]:
@@ -79,8 +288,17 @@ def from_snapshot(
79
288
for index , stack_frame in enumerate (reversed (stack )):
80
289
if is_cpython_internal (stack_frame ):
81
290
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
+ )
82
295
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
+ )
84
302
current_frame .children [stack_frame ] = node
85
303
86
304
current_frame = current_frame .children [stack_frame ]
@@ -91,64 +309,14 @@ def from_snapshot(
91
309
if index > MAX_STACKS :
92
310
break
93
311
94
- return cls (data .collapse_tree ())
312
+ return cls (data )
313
+
314
+ def get_app (self ) -> TreeApp :
315
+ return TreeApp (self .data )
95
316
96
317
def render (
97
318
self ,
98
319
* ,
99
320
file : Optional [IO [str ]] = None ,
100
321
) -> 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 ()
0 commit comments