mirror of
				https://github.com/python/cpython.git
				synced 2025-11-04 11:49:12 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			377 lines
		
	
	
	
		
			14 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			377 lines
		
	
	
	
		
			14 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
"""An IDLE extension to avoid having very long texts printed in the shell.
 | 
						|
 | 
						|
A common problem in IDLE's interactive shell is printing of large amounts of
 | 
						|
text into the shell. This makes looking at the previous history difficult.
 | 
						|
Worse, this can cause IDLE to become very slow, even to the point of being
 | 
						|
completely unusable.
 | 
						|
 | 
						|
This extension will automatically replace long texts with a small button.
 | 
						|
Double-clicking this button will remove it and insert the original text instead.
 | 
						|
Middle-clicking will copy the text to the clipboard. Right-clicking will open
 | 
						|
the text in a separate viewing window.
 | 
						|
 | 
						|
Additionally, any output can be manually "squeezed" by the user. This includes
 | 
						|
output written to the standard error stream ("stderr"), such as exception
 | 
						|
messages and their tracebacks.
 | 
						|
"""
 | 
						|
import re
 | 
						|
import weakref
 | 
						|
 | 
						|
import tkinter as tk
 | 
						|
from tkinter.font import Font
 | 
						|
import tkinter.messagebox as tkMessageBox
 | 
						|
 | 
						|
from idlelib.config import idleConf
 | 
						|
from idlelib.textview import view_text
 | 
						|
from idlelib.tooltip import Hovertip
 | 
						|
from idlelib import macosx
 | 
						|
 | 
						|
 | 
						|
def count_lines_with_wrapping(s, linewidth=80):
 | 
						|
    """Count the number of lines in a given string.
 | 
						|
 | 
						|
    Lines are counted as if the string was wrapped so that lines are never over
 | 
						|
    linewidth characters long.
 | 
						|
 | 
						|
    Tabs are considered tabwidth characters long.
 | 
						|
    """
 | 
						|
    tabwidth = 8  # Currently always true in Shell.
 | 
						|
    pos = 0
 | 
						|
    linecount = 1
 | 
						|
    current_column = 0
 | 
						|
 | 
						|
    for m in re.finditer(r"[\t\n]", s):
 | 
						|
        # Process the normal chars up to tab or newline.
 | 
						|
        numchars = m.start() - pos
 | 
						|
        pos += numchars
 | 
						|
        current_column += numchars
 | 
						|
 | 
						|
        # Deal with tab or newline.
 | 
						|
        if s[pos] == '\n':
 | 
						|
            # Avoid the `current_column == 0` edge-case, and while we're
 | 
						|
            # at it, don't bother adding 0.
 | 
						|
            if current_column > linewidth:
 | 
						|
                # If the current column was exactly linewidth, divmod
 | 
						|
                # would give (1,0), even though a new line hadn't yet
 | 
						|
                # been started. The same is true if length is any exact
 | 
						|
                # multiple of linewidth. Therefore, subtract 1 before
 | 
						|
                # dividing a non-empty line.
 | 
						|
                linecount += (current_column - 1) // linewidth
 | 
						|
            linecount += 1
 | 
						|
            current_column = 0
 | 
						|
        else:
 | 
						|
            assert s[pos] == '\t'
 | 
						|
            current_column += tabwidth - (current_column % tabwidth)
 | 
						|
 | 
						|
            # If a tab passes the end of the line, consider the entire
 | 
						|
            # tab as being on the next line.
 | 
						|
            if current_column > linewidth:
 | 
						|
                linecount += 1
 | 
						|
                current_column = tabwidth
 | 
						|
 | 
						|
        pos += 1 # After the tab or newline.
 | 
						|
 | 
						|
    # Process remaining chars (no more tabs or newlines).
 | 
						|
    current_column += len(s) - pos
 | 
						|
    # Avoid divmod(-1, linewidth).
 | 
						|
    if current_column > 0:
 | 
						|
        linecount += (current_column - 1) // linewidth
 | 
						|
    else:
 | 
						|
        # Text ended with newline; don't count an extra line after it.
 | 
						|
        linecount -= 1
 | 
						|
 | 
						|
    return linecount
 | 
						|
 | 
						|
 | 
						|
class ExpandingButton(tk.Button):
 | 
						|
    """Class for the "squeezed" text buttons used by Squeezer
 | 
						|
 | 
						|
    These buttons are displayed inside a Tk Text widget in place of text. A
 | 
						|
    user can then use the button to replace it with the original text, copy
 | 
						|
    the original text to the clipboard or view the original text in a separate
 | 
						|
    window.
 | 
						|
 | 
						|
    Each button is tied to a Squeezer instance, and it knows to update the
 | 
						|
    Squeezer instance when it is expanded (and therefore removed).
 | 
						|
    """
 | 
						|
    def __init__(self, s, tags, numoflines, squeezer):
 | 
						|
        self.s = s
 | 
						|
        self.tags = tags
 | 
						|
        self.numoflines = numoflines
 | 
						|
        self.squeezer = squeezer
 | 
						|
        self.editwin = editwin = squeezer.editwin
 | 
						|
        self.text = text = editwin.text
 | 
						|
        # The base Text widget is needed to change text before iomark.
 | 
						|
        self.base_text = editwin.per.bottom
 | 
						|
 | 
						|
        line_plurality = "lines" if numoflines != 1 else "line"
 | 
						|
        button_text = f"Squeezed text ({numoflines} {line_plurality})."
 | 
						|
        tk.Button.__init__(self, text, text=button_text,
 | 
						|
                           background="#FFFFC0", activebackground="#FFFFE0")
 | 
						|
 | 
						|
        button_tooltip_text = (
 | 
						|
            "Double-click to expand, right-click for more options."
 | 
						|
        )
 | 
						|
        Hovertip(self, button_tooltip_text, hover_delay=80)
 | 
						|
 | 
						|
        self.bind("<Double-Button-1>", self.expand)
 | 
						|
        if macosx.isAquaTk():
 | 
						|
            # AquaTk defines <2> as the right button, not <3>.
 | 
						|
            self.bind("<Button-2>", self.context_menu_event)
 | 
						|
        else:
 | 
						|
            self.bind("<Button-3>", self.context_menu_event)
 | 
						|
        self.selection_handle(  # X windows only.
 | 
						|
            lambda offset, length: s[int(offset):int(offset) + int(length)])
 | 
						|
 | 
						|
        self.is_dangerous = None
 | 
						|
        self.after_idle(self.set_is_dangerous)
 | 
						|
 | 
						|
    def set_is_dangerous(self):
 | 
						|
        dangerous_line_len = 50 * self.text.winfo_width()
 | 
						|
        self.is_dangerous = (
 | 
						|
            self.numoflines > 1000 or
 | 
						|
            len(self.s) > 50000 or
 | 
						|
            any(
 | 
						|
                len(line_match.group(0)) >= dangerous_line_len
 | 
						|
                for line_match in re.finditer(r'[^\n]+', self.s)
 | 
						|
            )
 | 
						|
        )
 | 
						|
 | 
						|
    def expand(self, event=None):
 | 
						|
        """expand event handler
 | 
						|
 | 
						|
        This inserts the original text in place of the button in the Text
 | 
						|
        widget, removes the button and updates the Squeezer instance.
 | 
						|
 | 
						|
        If the original text is dangerously long, i.e. expanding it could
 | 
						|
        cause a performance degradation, ask the user for confirmation.
 | 
						|
        """
 | 
						|
        if self.is_dangerous is None:
 | 
						|
            self.set_is_dangerous()
 | 
						|
        if self.is_dangerous:
 | 
						|
            confirm = tkMessageBox.askokcancel(
 | 
						|
                title="Expand huge output?",
 | 
						|
                message="\n\n".join([
 | 
						|
                    "The squeezed output is very long: %d lines, %d chars.",
 | 
						|
                    "Expanding it could make IDLE slow or unresponsive.",
 | 
						|
                    "It is recommended to view or copy the output instead.",
 | 
						|
                    "Really expand?"
 | 
						|
                ]) % (self.numoflines, len(self.s)),
 | 
						|
                default=tkMessageBox.CANCEL,
 | 
						|
                parent=self.text)
 | 
						|
            if not confirm:
 | 
						|
                return "break"
 | 
						|
 | 
						|
        self.base_text.insert(self.text.index(self), self.s, self.tags)
 | 
						|
        self.base_text.delete(self)
 | 
						|
        self.squeezer.expandingbuttons.remove(self)
 | 
						|
 | 
						|
    def copy(self, event=None):
 | 
						|
        """copy event handler
 | 
						|
 | 
						|
        Copy the original text to the clipboard.
 | 
						|
        """
 | 
						|
        self.clipboard_clear()
 | 
						|
        self.clipboard_append(self.s)
 | 
						|
 | 
						|
    def view(self, event=None):
 | 
						|
        """view event handler
 | 
						|
 | 
						|
        View the original text in a separate text viewer window.
 | 
						|
        """
 | 
						|
        view_text(self.text, "Squeezed Output Viewer", self.s,
 | 
						|
                  modal=False, wrap='none')
 | 
						|
 | 
						|
    rmenu_specs = (
 | 
						|
        # Item structure: (label, method_name).
 | 
						|
        ('copy', 'copy'),
 | 
						|
        ('view', 'view'),
 | 
						|
    )
 | 
						|
 | 
						|
    def context_menu_event(self, event):
 | 
						|
        self.text.mark_set("insert", "@%d,%d" % (event.x, event.y))
 | 
						|
        rmenu = tk.Menu(self.text, tearoff=0)
 | 
						|
        for label, method_name in self.rmenu_specs:
 | 
						|
            rmenu.add_command(label=label, command=getattr(self, method_name))
 | 
						|
        rmenu.tk_popup(event.x_root, event.y_root)
 | 
						|
        return "break"
 | 
						|
 | 
						|
 | 
						|
class Squeezer:
 | 
						|
    """Replace long outputs in the shell with a simple button.
 | 
						|
 | 
						|
    This avoids IDLE's shell slowing down considerably, and even becoming
 | 
						|
    completely unresponsive, when very long outputs are written.
 | 
						|
    """
 | 
						|
    _instance_weakref = None
 | 
						|
 | 
						|
    @classmethod
 | 
						|
    def reload(cls):
 | 
						|
        """Load class variables from config."""
 | 
						|
        cls.auto_squeeze_min_lines = idleConf.GetOption(
 | 
						|
            "main", "PyShell", "auto-squeeze-min-lines",
 | 
						|
            type="int", default=50,
 | 
						|
        )
 | 
						|
 | 
						|
        # Loading the font info requires a Tk root. IDLE doesn't rely
 | 
						|
        # on Tkinter's "default root", so the instance will reload
 | 
						|
        # font info using its editor windows's Tk root.
 | 
						|
        if cls._instance_weakref is not None:
 | 
						|
            instance = cls._instance_weakref()
 | 
						|
            if instance is not None:
 | 
						|
                instance.load_font()
 | 
						|
 | 
						|
    def __init__(self, editwin):
 | 
						|
        """Initialize settings for Squeezer.
 | 
						|
 | 
						|
        editwin is the shell's Editor window.
 | 
						|
        self.text is the editor window text widget.
 | 
						|
        self.base_test is the actual editor window Tk text widget, rather than
 | 
						|
            EditorWindow's wrapper.
 | 
						|
        self.expandingbuttons is the list of all buttons representing
 | 
						|
            "squeezed" output.
 | 
						|
        """
 | 
						|
        self.editwin = editwin
 | 
						|
        self.text = text = editwin.text
 | 
						|
 | 
						|
        # Get the base Text widget of the PyShell object, used to change
 | 
						|
        # text before the iomark. PyShell deliberately disables changing
 | 
						|
        # text before the iomark via its 'text' attribute, which is
 | 
						|
        # actually a wrapper for the actual Text widget. Squeezer,
 | 
						|
        # however, needs to make such changes.
 | 
						|
        self.base_text = editwin.per.bottom
 | 
						|
 | 
						|
        Squeezer._instance_weakref = weakref.ref(self)
 | 
						|
        self.load_font()
 | 
						|
 | 
						|
        # Twice the text widget's border width and internal padding;
 | 
						|
        # pre-calculated here for the get_line_width() method.
 | 
						|
        self.window_width_delta = 2 * (
 | 
						|
            int(text.cget('border')) +
 | 
						|
            int(text.cget('padx'))
 | 
						|
        )
 | 
						|
 | 
						|
        self.expandingbuttons = []
 | 
						|
 | 
						|
        # Replace the PyShell instance's write method with a wrapper,
 | 
						|
        # which inserts an ExpandingButton instead of a long text.
 | 
						|
        def mywrite(s, tags=(), write=editwin.write):
 | 
						|
            # Only auto-squeeze text which has just the "stdout" tag.
 | 
						|
            if tags != "stdout":
 | 
						|
                return write(s, tags)
 | 
						|
 | 
						|
            # Only auto-squeeze text with at least the minimum
 | 
						|
            # configured number of lines.
 | 
						|
            auto_squeeze_min_lines = self.auto_squeeze_min_lines
 | 
						|
            # First, a very quick check to skip very short texts.
 | 
						|
            if len(s) < auto_squeeze_min_lines:
 | 
						|
                return write(s, tags)
 | 
						|
            # Now the full line-count check.
 | 
						|
            numoflines = self.count_lines(s)
 | 
						|
            if numoflines < auto_squeeze_min_lines:
 | 
						|
                return write(s, tags)
 | 
						|
 | 
						|
            # Create an ExpandingButton instance.
 | 
						|
            expandingbutton = ExpandingButton(s, tags, numoflines, self)
 | 
						|
 | 
						|
            # Insert the ExpandingButton into the Text widget.
 | 
						|
            text.mark_gravity("iomark", tk.RIGHT)
 | 
						|
            text.window_create("iomark", window=expandingbutton,
 | 
						|
                               padx=3, pady=5)
 | 
						|
            text.see("iomark")
 | 
						|
            text.update()
 | 
						|
            text.mark_gravity("iomark", tk.LEFT)
 | 
						|
 | 
						|
            # Add the ExpandingButton to the Squeezer's list.
 | 
						|
            self.expandingbuttons.append(expandingbutton)
 | 
						|
 | 
						|
        editwin.write = mywrite
 | 
						|
 | 
						|
    def count_lines(self, s):
 | 
						|
        """Count the number of lines in a given text.
 | 
						|
 | 
						|
        Before calculation, the tab width and line length of the text are
 | 
						|
        fetched, so that up-to-date values are used.
 | 
						|
 | 
						|
        Lines are counted as if the string was wrapped so that lines are never
 | 
						|
        over linewidth characters long.
 | 
						|
 | 
						|
        Tabs are considered tabwidth characters long.
 | 
						|
        """
 | 
						|
        linewidth = self.get_line_width()
 | 
						|
        return count_lines_with_wrapping(s, linewidth)
 | 
						|
 | 
						|
    def get_line_width(self):
 | 
						|
        # The maximum line length in pixels: The width of the text
 | 
						|
        # widget, minus twice the border width and internal padding.
 | 
						|
        linewidth_pixels = \
 | 
						|
            self.base_text.winfo_width() - self.window_width_delta
 | 
						|
 | 
						|
        # Divide the width of the Text widget by the font width,
 | 
						|
        # which is taken to be the width of '0' (zero).
 | 
						|
        # http://www.tcl.tk/man/tcl8.6/TkCmd/text.htm#M21
 | 
						|
        return linewidth_pixels // self.zero_char_width
 | 
						|
 | 
						|
    def load_font(self):
 | 
						|
        text = self.base_text
 | 
						|
        self.zero_char_width = \
 | 
						|
            Font(text, font=text.cget('font')).measure('0')
 | 
						|
 | 
						|
    def squeeze_current_text_event(self, event):
 | 
						|
        """squeeze-current-text event handler
 | 
						|
 | 
						|
        Squeeze the block of text inside which contains the "insert" cursor.
 | 
						|
 | 
						|
        If the insert cursor is not in a squeezable block of text, give the
 | 
						|
        user a small warning and do nothing.
 | 
						|
        """
 | 
						|
        # Set tag_name to the first valid tag found on the "insert" cursor.
 | 
						|
        tag_names = self.text.tag_names(tk.INSERT)
 | 
						|
        for tag_name in ("stdout", "stderr"):
 | 
						|
            if tag_name in tag_names:
 | 
						|
                break
 | 
						|
        else:
 | 
						|
            # The insert cursor doesn't have a "stdout" or "stderr" tag.
 | 
						|
            self.text.bell()
 | 
						|
            return "break"
 | 
						|
 | 
						|
        # Find the range to squeeze.
 | 
						|
        start, end = self.text.tag_prevrange(tag_name, tk.INSERT + "+1c")
 | 
						|
        s = self.text.get(start, end)
 | 
						|
 | 
						|
        # If the last char is a newline, remove it from the range.
 | 
						|
        if len(s) > 0 and s[-1] == '\n':
 | 
						|
            end = self.text.index("%s-1c" % end)
 | 
						|
            s = s[:-1]
 | 
						|
 | 
						|
        # Delete the text.
 | 
						|
        self.base_text.delete(start, end)
 | 
						|
 | 
						|
        # Prepare an ExpandingButton.
 | 
						|
        numoflines = self.count_lines(s)
 | 
						|
        expandingbutton = ExpandingButton(s, tag_name, numoflines, self)
 | 
						|
 | 
						|
        # insert the ExpandingButton to the Text
 | 
						|
        self.text.window_create(start, window=expandingbutton,
 | 
						|
                                padx=3, pady=5)
 | 
						|
 | 
						|
        # Insert the ExpandingButton to the list of ExpandingButtons,
 | 
						|
        # while keeping the list ordered according to the position of
 | 
						|
        # the buttons in the Text widget.
 | 
						|
        i = len(self.expandingbuttons)
 | 
						|
        while i > 0 and self.text.compare(self.expandingbuttons[i-1],
 | 
						|
                                          ">", expandingbutton):
 | 
						|
            i -= 1
 | 
						|
        self.expandingbuttons.insert(i, expandingbutton)
 | 
						|
 | 
						|
        return "break"
 | 
						|
 | 
						|
 | 
						|
Squeezer.reload()
 | 
						|
 | 
						|
 | 
						|
if __name__ == "__main__":
 | 
						|
    from unittest import main
 | 
						|
    main('idlelib.idle_test.test_squeezer', verbosity=2, exit=False)
 | 
						|
 | 
						|
    # Add htest.
 |