1import ast
2import logging
3import tkinter as tk
4from tkinter import ttk, messagebox
5
6import thonny.memory
7from thonny import get_runner, get_workbench, ui_utils
8from thonny.common import InlineCommand
9from thonny.languages import tr
10from thonny.memory import MemoryFrame
11from thonny.misc_utils import shorten_repr
12from thonny.tktextext import TextFrame
13from thonny.ui_utils import ems_to_pixels
14
15
16class ObjectInspector(ttk.Frame):
17    def __init__(self, master):
18        ttk.Frame.__init__(self, master, style="ViewBody.TFrame")
19
20        self.object_id = None
21        self.object_info = None
22
23        # self._create_general_page()
24        self._create_content_page()
25        self._create_attributes_page()
26        self.active_page = self.content_page
27        self.active_page.grid(row=1, column=0, sticky="nsew")
28
29        toolbar = self._create_toolbar()
30        toolbar.grid(row=0, column=0, sticky="nsew", pady=(0, 1))
31
32        self.columnconfigure(0, weight=1)
33        self.rowconfigure(1, weight=1)
34
35        get_workbench().bind("ObjectSelect", self.show_object, True)
36        get_workbench().bind("get_object_info_response", self._handle_object_info_event, True)
37        get_workbench().bind("DebuggerResponse", self._handle_progress_event, True)
38        get_workbench().bind("ToplevelResponse", self._handle_progress_event, True)
39        get_workbench().bind("BackendRestart", self._on_backend_restart, True)
40
41        # self.demo()
42
43    def _create_toolbar(self):
44        toolbar = ttk.Frame(self, style="ViewToolbar.TFrame")
45
46        self.title_label = ttk.Label(
47            toolbar,
48            style="ViewToolbar.TLabel",
49            text=""
50            # borderwidth=1,
51            # background=ui_utils.get_main_background()
52        )
53        self.title_label.grid(row=0, column=3, sticky="nsew", pady=5, padx=5)
54        toolbar.columnconfigure(3, weight=1)
55
56        self.tabs = []
57
58        def create_tab(col, caption, page):
59            if page == self.active_page:
60                style = "Active.ViewTab.TLabel"
61            else:
62                style = "Inactive.ViewTab.TLabel"
63            tab = ttk.Label(toolbar, text=caption, style=style)
64            tab.grid(row=0, column=col, pady=5, padx=5, sticky="nsew")
65            self.tabs.append(tab)
66            page.tab = tab
67
68            def on_click(event):
69                if self.active_page == page:
70                    return
71                else:
72                    if self.active_page is not None:
73                        self.active_page.grid_forget()
74                        self.active_page.tab.configure(style="Inactive.ViewTab.TLabel")
75
76                    self.active_page = page
77                    page.grid(row=1, column=0, sticky="nsew", padx=0)
78                    tab.configure(style="Active.ViewTab.TLabel")
79                    if (
80                        self.active_page == self.attributes_page
81                        and (self.object_info is None or not self.object_info.get("attributes"))
82                        and self.object_id is not None
83                    ):
84                        self.request_object_info()
85
86            tab.bind("<1>", on_click)
87
88        # create_tab(1, "Overview", self.general_page)
89        create_tab(5, tr("Data"), self.content_page)
90        create_tab(6, tr("Attributes"), self.attributes_page)
91
92        def create_navigation_link(col, image_filename, action, tooltip, padx=0):
93            button = ttk.Button(
94                toolbar,
95                # command=handler,
96                image=get_workbench().get_image(image_filename),
97                style="ViewToolbar.Toolbutton",  # TODO: does this cause problems in some Macs?
98                state=tk.NORMAL,
99            )
100            ui_utils.create_tooltip(button, tooltip)
101
102            button.grid(row=0, column=col, sticky=tk.NE, padx=padx, pady=4)
103            button.bind("<Button-1>", action)
104            return button
105
106        def configure(event):
107            if event.width > 20:
108                self.title_label.configure(wraplength=event.width - 10)
109
110        self.title_label.bind("<Configure>", configure, True)
111
112        self.back_button = create_navigation_link(
113            1, "nav-backward", self.navigate_back, tr("Previous object"), (5, 0)
114        )
115        self.forward_button = create_navigation_link(
116            2, "nav-forward", self.navigate_forward, tr("Next object")
117        )
118        self.back_links = []
119        self.forward_links = []
120
121        return toolbar
122
123    def _create_content_page(self):
124        self.content_page = ttk.Frame(self, style="ViewBody.TFrame")
125        # type-specific inspectors
126        self.current_content_inspector = None
127        self.content_inspectors = []
128        # load custom inspectors
129        for insp_class in get_workbench().content_inspector_classes:
130            self.content_inspectors.append(insp_class(self.content_page))
131
132        # read standard inspectors
133        self.content_inspectors.extend(
134            [
135                FileHandleInspector(self.content_page),
136                FunctionInspector(self.content_page),
137                StringInspector(self.content_page),
138                ElementsInspector(self.content_page),
139                DictInspector(self.content_page),
140                ImageInspector(self.content_page),
141                IntInspector(self.content_page),
142                FloatInspector(self.content_page),
143                ReprInspector(self.content_page),  # fallback content inspector
144            ]
145        )
146
147        self.content_page.columnconfigure(0, weight=1)
148        self.content_page.rowconfigure(0, weight=1)
149
150    def _create_attributes_page(self):
151        self.attributes_page = AttributesFrame(self)
152
153    def navigate_back(self, event):
154        if len(self.back_links) == 0:
155            return
156
157        self.forward_links.append(self.object_id)
158        self._show_object_by_id(self.back_links.pop(), True)
159
160    def navigate_forward(self, event):
161        if len(self.forward_links) == 0:
162            return
163
164        self.back_links.append(self.object_id)
165        self._show_object_by_id(self.forward_links.pop(), True)
166
167    def show_object(self, event):
168        self._show_object_by_id(event.object_id)
169
170    def _show_object_by_id(self, object_id, via_navigation=False):
171        assert object_id is not None
172
173        if self.winfo_ismapped() and self.object_id != object_id:
174            if not via_navigation and self.object_id is not None:
175                if self.object_id in self.back_links:
176                    self.back_links.remove(self.object_id)
177                self.back_links.append(self.object_id)
178                del self.forward_links[:]
179
180            context_id = self.object_id
181            self.object_id = object_id
182            self.set_object_info(None)
183            self._set_title("object @ " + thonny.memory.format_object_id(object_id))
184            self.request_object_info(context_id=context_id)
185
186    def _on_backend_restart(self, event=None):
187        self.set_object_info(None)
188        self.object_id = None
189
190    def _set_title(self, text):
191        self.title_label.configure(text=text)
192
193    def _handle_object_info_event(self, msg):
194        if self.winfo_ismapped():
195            if msg.get("error") and not msg.get("info"):
196                self.set_object_info({"error": msg["error"]})
197                return
198
199            if msg.info["id"] == self.object_id:
200                if hasattr(msg, "not_found") and msg.not_found:
201                    self.object_id = None
202                    self.set_object_info(None)
203                else:
204                    self.set_object_info(msg.info)
205
206    def _handle_progress_event(self, event):
207        if self.object_id is not None:
208            # refresh
209            self.request_object_info()
210
211    def request_object_info(self, context_id=None):
212        # current width and height of the frame are required for
213        # some content providers
214        if self.active_page is not None:
215            frame_width = self.active_page.winfo_width()
216            frame_height = self.active_page.winfo_height()
217
218            # in some cases measures are inaccurate
219            if frame_width < 5 or frame_height < 5:
220                frame_width = None
221                frame_height = None
222        else:
223            frame_width = None
224            frame_height = None
225
226        get_runner().send_command(
227            InlineCommand(
228                "get_object_info",
229                object_id=self.object_id,
230                context_id=context_id,
231                back_links=self.back_links,
232                forward_links=self.forward_links,
233                include_attributes=self.active_page == self.attributes_page,
234                all_attributes=False,
235                frame_width=frame_width,
236                frame_height=frame_height,
237            )
238        )
239
240    def set_object_info(self, object_info):
241        self.object_info = object_info
242        if object_info is None or "error" in object_info:
243            if object_info is None:
244                self._set_title("")
245            else:
246                self._set_title(object_info["error"])
247            if self.current_content_inspector is not None:
248                self.current_content_inspector.grid_remove()
249                self.current_content_inspector = None
250            self.attributes_page.clear()
251        else:
252            self._set_title(
253                object_info["full_type_name"]
254                + " @ "
255                + thonny.memory.format_object_id(object_info["id"])
256            )
257            self.attributes_page.update_variables(object_info["attributes"])
258            self.attributes_page.context_id = object_info["id"]
259            self.update_type_specific_info(object_info)
260
261            # update layout
262            # self._expose(None)
263            # if not self.grid_frame.winfo_ismapped():
264            #    self.grid_frame.grid()
265
266        """
267        if self.back_links == []:
268            self.back_label.config(foreground="lightgray", cursor="arrow")
269        else:
270            self.back_label.config(foreground="blue", cursor="hand2")
271
272        if self.forward_links == []:
273            self.forward_label.config(foreground="lightgray", cursor="arrow")
274        else:
275            self.forward_label.config(foreground="blue", cursor="hand2")
276        """
277
278    def update_type_specific_info(self, object_info):
279        content_inspector = None
280        for insp in self.content_inspectors:
281            if insp.applies_to(object_info):
282                content_inspector = insp
283                break
284
285        if content_inspector != self.current_content_inspector:
286            if self.current_content_inspector is not None:
287                self.current_content_inspector.grid_remove()  # TODO: or forget?
288                self.current_content_inspector = None
289
290            if content_inspector is not None:
291                content_inspector.grid(row=0, column=0, sticky=tk.NSEW, padx=(0, 0))
292
293            self.current_content_inspector = content_inspector
294
295        if self.current_content_inspector is not None:
296            self.current_content_inspector.set_object_info(object_info)
297
298
299class ContentInspector:
300    def __init__(self, master):
301        pass
302
303    def set_object_info(self, object_info):
304        pass
305
306    def get_tab_text(self):
307        return "Data"
308
309    def applies_to(self, object_info):
310        return False
311
312
313class FileHandleInspector(TextFrame, ContentInspector):
314    def __init__(self, master):
315        ContentInspector.__init__(self, master)
316        TextFrame.__init__(self, master, read_only=True)
317        self.cache = {}  # stores file contents for handle id-s
318        self.config(borderwidth=1)
319        self.text.configure(background="white")
320        self.text.tag_configure("read", foreground="lightgray")
321
322    def applies_to(self, object_info):
323        return "file_content" in object_info or "file_error" in object_info
324
325    def set_object_info(self, object_info):
326
327        if "file_content" not in object_info:
328            logging.exception("File error: " + object_info["file_error"])
329            return
330
331        assert "file_content" in object_info
332        content = object_info["file_content"]
333        line_count_sep = len(content.split("\n"))
334        # line_count_term = len(content.splitlines())
335        # char_count = len(content)
336        self.text.configure(height=min(line_count_sep, 10))
337        self.text.set_content(content)
338
339        assert "file_tell" in object_info
340        # f.tell() gives num of bytes read (minus some magic with linebreaks)
341
342        file_bytes = content.encode(encoding=object_info["file_encoding"])
343        bytes_read = file_bytes[0 : object_info["file_tell"]]
344        read_content = bytes_read.decode(encoding=object_info["file_encoding"])
345        read_char_count = len(read_content)
346        # read_line_count_term = (len(content.splitlines())
347        #                        - len(content[read_char_count:].splitlines()))
348
349        pos_index = "1.0+" + str(read_char_count) + "c"
350        self.text.tag_add("read", "1.0", pos_index)
351        self.text.see(pos_index)
352
353        # TODO: show this info somewhere
354        """
355        label.configure(text="Read %d/%d %s, %d/%d %s"
356                        % (read_char_count,
357                           char_count,
358                           "symbol" if char_count == 1 else "symbols",
359                           read_line_count_term,
360                           line_count_term,
361                           "line" if line_count_term == 1 else "lines"))
362        """
363
364
365class FunctionInspector(TextFrame, ContentInspector):
366    def __init__(self, master):
367        ContentInspector.__init__(self, master)
368        TextFrame.__init__(self, master, read_only=True)
369        self.text.configure(background="white")
370
371    def applies_to(self, object_info):
372        return "source" in object_info
373
374    def get_tab_text(self):
375        return "Code"
376
377    def set_object_info(self, object_info):
378        line_count = len(object_info["source"].split("\n"))
379        self.text.configure(height=min(line_count, 15))
380        self.text.set_content(object_info["source"])
381
382
383class StringInspector(TextFrame, ContentInspector):
384    def __init__(self, master):
385        ContentInspector.__init__(self, master)
386        TextFrame.__init__(self, master, read_only=True)
387        # self.config(borderwidth=1)
388        # self.text.configure(background="white")
389
390    def applies_to(self, object_info):
391        return object_info["type"] == repr(str)
392
393    def set_object_info(self, object_info):
394        # TODO: don't show too big string
395        try:
396            content = ast.literal_eval(object_info["repr"])
397        except SyntaxError:
398            try:
399                # can be shortened
400                content = ast.literal_eval(object_info["repr"] + object_info["repr"][0:1])
401            except SyntaxError:
402                content = "<can't show string content>"
403
404        line_count_sep = len(content.split("\n"))
405        # line_count_term = len(content.splitlines())
406        self.text.configure(height=min(line_count_sep, 10))
407        self.text.set_content(content)
408        """ TODO:
409        label.configure(text="%d %s, %d %s"
410                        % (len(content),
411                           "symbol" if len(content) == 1 else "symbols",
412                           line_count_term,
413                           "line" if line_count_term == 1 else "lines"))
414        """
415
416
417class IntInspector(TextFrame, ContentInspector):
418    def __init__(self, master):
419        ContentInspector.__init__(self, master)
420        TextFrame.__init__(
421            self, master, read_only=True, horizontal_scrollbar=False, font="TkDefaultFont"
422        )
423
424    def applies_to(self, object_info):
425        return object_info["type"] == repr(int)
426
427    def set_object_info(self, object_info):
428        content = ast.literal_eval(object_info["repr"])
429        self.text.set_content(
430            object_info["repr"]
431            + "\n\n"
432            + "bin: "
433            + bin(content)
434            + "\n"
435            + "oct: "
436            + oct(content)
437            + "\n"
438            + "hex: "
439            + hex(content)
440            + "\n"
441        )
442
443
444class FloatInspector(TextFrame, ContentInspector):
445    def __init__(self, master):
446        ContentInspector.__init__(self, master)
447        TextFrame.__init__(
448            self,
449            master,
450            read_only=True,
451            horizontal_scrollbar=False,
452            wrap="word",
453            font="TkDefaultFont",
454        )
455
456    def applies_to(self, object_info):
457        return object_info["type"] == repr(float)
458
459    def set_object_info(self, object_info):
460        content = object_info["repr"] + "\n\n\n"
461
462        if "as_integer_ratio" in object_info:
463            ratio = object_info["as_integer_ratio"]
464            from decimal import Decimal
465
466            ratio_dec_str = str(Decimal(ratio[0]) / Decimal(ratio[1]))
467
468            if ratio_dec_str != object_info["repr"]:
469                explanation = tr(
470                    "The representation above is an approximate value of this float. "
471                    "The exact stored value is %s which is about %s"
472                )
473
474                content += explanation % (
475                    "\n\n  %d / %d\n\n" % ratio,
476                    "\n\n  %s\n\n" % ratio_dec_str,
477                )
478
479        self.text.set_content(content)
480
481
482class ReprInspector(TextFrame, ContentInspector):
483    def __init__(self, master):
484        ContentInspector.__init__(self, master)
485        TextFrame.__init__(self, master, read_only=True)
486        # self.config(borderwidth=1)
487        # self.text.configure(background="white")
488
489    def applies_to(self, object_info):
490        return True
491
492    def set_object_info(self, object_info):
493        # TODO: don't show too big string
494        content = object_info["repr"]
495        self.text.set_content(content)
496        """
497        line_count_sep = len(content.split("\n"))
498        line_count_term = len(content.splitlines())
499        self.text.configure(height=min(line_count_sep, 10))
500        label.configure(text="%d %s, %d %s"
501                        % (len(content),
502                           "symbol" if len(content) == 1 else "symbols",
503                           line_count_term,
504                           "line" if line_count_term == 1 else "lines"))
505        """
506
507
508class ElementsInspector(thonny.memory.MemoryFrame, ContentInspector):
509    def __init__(self, master):
510        ContentInspector.__init__(self, master)
511        thonny.memory.MemoryFrame.__init__(
512            self, master, ("index", "id", "value"), show_statusbar=True
513        )
514
515        # self.vert_scrollbar.grid_remove()
516        self.tree.column("index", width=ems_to_pixels(4), anchor=tk.W, stretch=False)
517        self.tree.column("id", width=750, anchor=tk.W, stretch=True)
518        self.tree.column("value", width=750, anchor=tk.W, stretch=True)
519
520        self.tree.heading("index", text=tr("Index"), anchor=tk.W)
521        self.tree.heading("id", text=tr("Value ID"), anchor=tk.W)
522        self.tree.heading("value", text=tr("Value"), anchor=tk.W)
523
524        self.len_label = ttk.Label(self.statusbar, text="", anchor="w")
525        self.len_label.grid(row=0, column=0, sticky="w")
526        self.statusbar.columnconfigure(0, weight=1)
527
528        self.elements_have_indices = None
529        self.update_memory_model()
530
531        get_workbench().bind("ShowView", self.update_memory_model, True)
532        get_workbench().bind("HideView", self.update_memory_model, True)
533
534    def update_memory_model(self, event=None):
535        self._update_columns()
536
537    def _update_columns(self):
538        if get_workbench().in_heap_mode():
539            if self.elements_have_indices:
540                self.tree.configure(displaycolumns=("index", "id"))
541            else:
542                self.tree.configure(displaycolumns=("id",))
543        else:
544            if self.elements_have_indices:
545                self.tree.configure(displaycolumns=("index", "value"))
546            else:
547                self.tree.configure(displaycolumns=("value"))
548
549    def applies_to(self, object_info):
550        return "elements" in object_info
551
552    def on_select(self, event):
553        pass
554
555    def on_double_click(self, event):
556        self.show_selected_object_info()
557
558    def set_object_info(self, object_info):
559        assert "elements" in object_info
560
561        self.elements_have_indices = object_info["type"] in (repr(tuple), repr(list))
562        self._update_columns()
563        self.context_id = object_info["id"]
564
565        self._clear_tree()
566        index = 0
567        # TODO: don't show too big number of elements
568        for element in object_info["elements"]:
569            node_id = self.tree.insert("", "end")
570            if self.elements_have_indices:
571                self.tree.set(node_id, "index", index)
572            else:
573                self.tree.set(node_id, "index", "")
574
575            self.tree.set(node_id, "id", thonny.memory.format_object_id(element.id))
576            self.tree.set(
577                node_id, "value", shorten_repr(element.repr, thonny.memory.MAX_REPR_LENGTH_IN_GRID)
578            )
579            index += 1
580
581        count = len(object_info["elements"])
582        self.len_label.configure(text=" len: %d" % count)
583
584
585class DictInspector(thonny.memory.MemoryFrame, ContentInspector):
586    def __init__(self, master):
587        ContentInspector.__init__(self, master)
588        thonny.memory.MemoryFrame.__init__(
589            self, master, ("key_id", "id", "key", "value"), show_statusbar=True
590        )
591        # self.configure(border=1)
592        # self.vert_scrollbar.grid_remove()
593        self.tree.column("key_id", width=ems_to_pixels(7), anchor=tk.W, stretch=False)
594        self.tree.column("key", width=100, anchor=tk.W, stretch=False)
595        self.tree.column("id", width=750, anchor=tk.W, stretch=True)
596        self.tree.column("value", width=750, anchor=tk.W, stretch=True)
597
598        self.tree.heading("key_id", text=tr("Key ID"), anchor=tk.W)
599        self.tree.heading("key", text=tr("Key"), anchor=tk.W)
600        self.tree.heading("id", text=tr("Value ID"), anchor=tk.W)
601        self.tree.heading("value", text=tr("Value"), anchor=tk.W)
602
603        self.len_label = ttk.Label(self.statusbar, text="", anchor="w")
604        self.len_label.grid(row=0, column=0, sticky="w")
605        self.statusbar.columnconfigure(0, weight=1)
606
607        self.update_memory_model()
608
609    def update_memory_model(self, event=None):
610        if get_workbench().in_heap_mode():
611            self.tree.configure(displaycolumns=("key_id", "id"))
612        else:
613            self.tree.configure(displaycolumns=("key", "value"))
614
615    def applies_to(self, object_info):
616        return "entries" in object_info
617
618    def on_select(self, event):
619        pass
620
621    def on_double_click(self, event):
622        # NB! this selects value
623        self.show_selected_object_info()
624
625    def set_object_info(self, object_info):
626        assert "entries" in object_info
627        self.context_id = object_info["id"]
628
629        self._clear_tree()
630        # TODO: don't show too big number of elements
631        for key, value in object_info["entries"]:
632            node_id = self.tree.insert("", "end")
633            self.tree.set(node_id, "key_id", thonny.memory.format_object_id(key.id))
634            self.tree.set(
635                node_id, "key", shorten_repr(key.repr, thonny.memory.MAX_REPR_LENGTH_IN_GRID)
636            )
637            self.tree.set(node_id, "id", thonny.memory.format_object_id(value.id))
638            self.tree.set(
639                node_id, "value", shorten_repr(value.repr, thonny.memory.MAX_REPR_LENGTH_IN_GRID)
640            )
641
642        count = len(object_info["entries"])
643        self.len_label.configure(text=" len: %d" % count)
644        self.update_memory_model()
645
646
647class ImageInspector(ContentInspector, tk.Frame):
648    def __init__(self, master):
649        tk.Frame.__init__(self, master)
650        ContentInspector.__init__(self, master)
651        self.label = tk.Label(self, anchor="nw")
652        self.label.grid(row=0, column=0, sticky="nsew")
653        self.rowconfigure(0, weight=1)
654        self.columnconfigure(0, weight=1)
655
656    def set_object_info(self, object_info):
657        if isinstance(object_info["image_data"], bytes):
658            import base64
659
660            data = base64.b64encode(object_info["image_data"])
661        elif isinstance(object_info["image_data"], str):
662            data = object_info["image_data"]
663        else:
664            self.label.configure(
665                image=None, text="Unsupported image data (%s)" % type(object_info["image_data"])
666            )
667            return
668
669        try:
670            self.image = tk.PhotoImage(data=data)
671            self.label.configure(image=self.image)
672        except Exception as e:
673            self.label.configure(image=None, text="Unsupported image data (%s)" % e)
674
675    def applies_to(self, object_info):
676        return "image_data" in object_info
677
678
679class AttributesFrame(thonny.memory.VariablesFrame):
680    def __init__(self, master):
681        thonny.memory.VariablesFrame.__init__(self, master)
682        self.configure(border=0)
683
684    def on_select(self, event):
685        pass
686
687    def on_double_click(self, event):
688        self.show_selected_object_info()
689
690    def show_selected_object_info(self):
691        object_id = self.get_object_id()
692        if object_id is None:
693            return
694
695        iid = self.tree.focus()
696        if not iid:
697            return
698        repr_str = self.tree.item(iid)["values"][2]
699
700        if repr_str == "<bound_method>":
701            from thonny.plugins.micropython import MicroPythonProxy
702
703            if isinstance(get_runner().get_backend_proxy(), MicroPythonProxy):
704                messagebox.showinfo(
705                    "Not supported",
706                    "Inspecting bound methods is not supported with MicroPython",
707                    master=self,
708                )
709                return
710
711        get_workbench().event_generate("ObjectSelect", object_id=object_id)
712
713
714def load_plugin() -> None:
715    get_workbench().add_view(ObjectInspector, tr("Object inspector"), "se")
716