(root)/
Python-3.12.0/
Lib/
idlelib/
squeezer.py
       1  """An IDLE extension to avoid having very long texts printed in the shell.
       2  
       3  A common problem in IDLE's interactive shell is printing of large amounts of
       4  text into the shell. This makes looking at the previous history difficult.
       5  Worse, this can cause IDLE to become very slow, even to the point of being
       6  completely unusable.
       7  
       8  This extension will automatically replace long texts with a small button.
       9  Double-clicking this button will remove it and insert the original text instead.
      10  Middle-clicking will copy the text to the clipboard. Right-clicking will open
      11  the text in a separate viewing window.
      12  
      13  Additionally, any output can be manually "squeezed" by the user. This includes
      14  output written to the standard error stream ("stderr"), such as exception
      15  messages and their tracebacks.
      16  """
      17  import re
      18  
      19  import tkinter as tk
      20  from tkinter import messagebox
      21  
      22  from idlelib.config import idleConf
      23  from idlelib.textview import view_text
      24  from idlelib.tooltip import Hovertip
      25  from idlelib import macosx
      26  
      27  
      28  def count_lines_with_wrapping(s, linewidth=80):
      29      """Count the number of lines in a given string.
      30  
      31      Lines are counted as if the string was wrapped so that lines are never over
      32      linewidth characters long.
      33  
      34      Tabs are considered tabwidth characters long.
      35      """
      36      tabwidth = 8  # Currently always true in Shell.
      37      pos = 0
      38      linecount = 1
      39      current_column = 0
      40  
      41      for m in re.finditer(r"[\t\n]", s):
      42          # Process the normal chars up to tab or newline.
      43          numchars = m.start() - pos
      44          pos += numchars
      45          current_column += numchars
      46  
      47          # Deal with tab or newline.
      48          if s[pos] == '\n':
      49              # Avoid the `current_column == 0` edge-case, and while we're
      50              # at it, don't bother adding 0.
      51              if current_column > linewidth:
      52                  # If the current column was exactly linewidth, divmod
      53                  # would give (1,0), even though a new line hadn't yet
      54                  # been started. The same is true if length is any exact
      55                  # multiple of linewidth. Therefore, subtract 1 before
      56                  # dividing a non-empty line.
      57                  linecount += (current_column - 1) // linewidth
      58              linecount += 1
      59              current_column = 0
      60          else:
      61              assert s[pos] == '\t'
      62              current_column += tabwidth - (current_column % tabwidth)
      63  
      64              # If a tab passes the end of the line, consider the entire
      65              # tab as being on the next line.
      66              if current_column > linewidth:
      67                  linecount += 1
      68                  current_column = tabwidth
      69  
      70          pos += 1 # After the tab or newline.
      71  
      72      # Process remaining chars (no more tabs or newlines).
      73      current_column += len(s) - pos
      74      # Avoid divmod(-1, linewidth).
      75      if current_column > 0:
      76          linecount += (current_column - 1) // linewidth
      77      else:
      78          # Text ended with newline; don't count an extra line after it.
      79          linecount -= 1
      80  
      81      return linecount
      82  
      83  
      84  class ESC[4;38;5;81mExpandingButton(ESC[4;38;5;149mtkESC[4;38;5;149m.ESC[4;38;5;149mButton):
      85      """Class for the "squeezed" text buttons used by Squeezer
      86  
      87      These buttons are displayed inside a Tk Text widget in place of text. A
      88      user can then use the button to replace it with the original text, copy
      89      the original text to the clipboard or view the original text in a separate
      90      window.
      91  
      92      Each button is tied to a Squeezer instance, and it knows to update the
      93      Squeezer instance when it is expanded (and therefore removed).
      94      """
      95      def __init__(self, s, tags, numoflines, squeezer):
      96          self.s = s
      97          self.tags = tags
      98          self.numoflines = numoflines
      99          self.squeezer = squeezer
     100          self.editwin = editwin = squeezer.editwin
     101          self.text = text = editwin.text
     102          # The base Text widget is needed to change text before iomark.
     103          self.base_text = editwin.per.bottom
     104  
     105          line_plurality = "lines" if numoflines != 1 else "line"
     106          button_text = f"Squeezed text ({numoflines} {line_plurality})."
     107          tk.Button.__init__(self, text, text=button_text,
     108                             background="#FFFFC0", activebackground="#FFFFE0")
     109  
     110          button_tooltip_text = (
     111              "Double-click to expand, right-click for more options."
     112          )
     113          Hovertip(self, button_tooltip_text, hover_delay=80)
     114  
     115          self.bind("<Double-Button-1>", self.expand)
     116          if macosx.isAquaTk():
     117              # AquaTk defines <2> as the right button, not <3>.
     118              self.bind("<Button-2>", self.context_menu_event)
     119          else:
     120              self.bind("<Button-3>", self.context_menu_event)
     121          self.selection_handle(  # X windows only.
     122              lambda offset, length: s[int(offset):int(offset) + int(length)])
     123  
     124          self.is_dangerous = None
     125          self.after_idle(self.set_is_dangerous)
     126  
     127      def set_is_dangerous(self):
     128          dangerous_line_len = 50 * self.text.winfo_width()
     129          self.is_dangerous = (
     130              self.numoflines > 1000 or
     131              len(self.s) > 50000 or
     132              any(
     133                  len(line_match.group(0)) >= dangerous_line_len
     134                  for line_match in re.finditer(r'[^\n]+', self.s)
     135              )
     136          )
     137  
     138      def expand(self, event=None):
     139          """expand event handler
     140  
     141          This inserts the original text in place of the button in the Text
     142          widget, removes the button and updates the Squeezer instance.
     143  
     144          If the original text is dangerously long, i.e. expanding it could
     145          cause a performance degradation, ask the user for confirmation.
     146          """
     147          if self.is_dangerous is None:
     148              self.set_is_dangerous()
     149          if self.is_dangerous:
     150              confirm = messagebox.askokcancel(
     151                  title="Expand huge output?",
     152                  message="\n\n".join([
     153                      "The squeezed output is very long: %d lines, %d chars.",
     154                      "Expanding it could make IDLE slow or unresponsive.",
     155                      "It is recommended to view or copy the output instead.",
     156                      "Really expand?"
     157                  ]) % (self.numoflines, len(self.s)),
     158                  default=messagebox.CANCEL,
     159                  parent=self.text)
     160              if not confirm:
     161                  return "break"
     162  
     163          index = self.text.index(self)
     164          self.base_text.insert(index, self.s, self.tags)
     165          self.base_text.delete(self)
     166          self.editwin.on_squeezed_expand(index, self.s, self.tags)
     167          self.squeezer.expandingbuttons.remove(self)
     168  
     169      def copy(self, event=None):
     170          """copy event handler
     171  
     172          Copy the original text to the clipboard.
     173          """
     174          self.clipboard_clear()
     175          self.clipboard_append(self.s)
     176  
     177      def view(self, event=None):
     178          """view event handler
     179  
     180          View the original text in a separate text viewer window.
     181          """
     182          view_text(self.text, "Squeezed Output Viewer", self.s,
     183                    modal=False, wrap='none')
     184  
     185      rmenu_specs = (
     186          # Item structure: (label, method_name).
     187          ('copy', 'copy'),
     188          ('view', 'view'),
     189      )
     190  
     191      def context_menu_event(self, event):
     192          self.text.mark_set("insert", "@%d,%d" % (event.x, event.y))
     193          rmenu = tk.Menu(self.text, tearoff=0)
     194          for label, method_name in self.rmenu_specs:
     195              rmenu.add_command(label=label, command=getattr(self, method_name))
     196          rmenu.tk_popup(event.x_root, event.y_root)
     197          return "break"
     198  
     199  
     200  class ESC[4;38;5;81mSqueezer:
     201      """Replace long outputs in the shell with a simple button.
     202  
     203      This avoids IDLE's shell slowing down considerably, and even becoming
     204      completely unresponsive, when very long outputs are written.
     205      """
     206      @classmethod
     207      def reload(cls):
     208          """Load class variables from config."""
     209          cls.auto_squeeze_min_lines = idleConf.GetOption(
     210              "main", "PyShell", "auto-squeeze-min-lines",
     211              type="int", default=50,
     212          )
     213  
     214      def __init__(self, editwin):
     215          """Initialize settings for Squeezer.
     216  
     217          editwin is the shell's Editor window.
     218          self.text is the editor window text widget.
     219          self.base_test is the actual editor window Tk text widget, rather than
     220              EditorWindow's wrapper.
     221          self.expandingbuttons is the list of all buttons representing
     222              "squeezed" output.
     223          """
     224          self.editwin = editwin
     225          self.text = text = editwin.text
     226  
     227          # Get the base Text widget of the PyShell object, used to change
     228          # text before the iomark. PyShell deliberately disables changing
     229          # text before the iomark via its 'text' attribute, which is
     230          # actually a wrapper for the actual Text widget. Squeezer,
     231          # however, needs to make such changes.
     232          self.base_text = editwin.per.bottom
     233  
     234          # Twice the text widget's border width and internal padding;
     235          # pre-calculated here for the get_line_width() method.
     236          self.window_width_delta = 2 * (
     237              int(text.cget('border')) +
     238              int(text.cget('padx'))
     239          )
     240  
     241          self.expandingbuttons = []
     242  
     243          # Replace the PyShell instance's write method with a wrapper,
     244          # which inserts an ExpandingButton instead of a long text.
     245          def mywrite(s, tags=(), write=editwin.write):
     246              # Only auto-squeeze text which has just the "stdout" tag.
     247              if tags != "stdout":
     248                  return write(s, tags)
     249  
     250              # Only auto-squeeze text with at least the minimum
     251              # configured number of lines.
     252              auto_squeeze_min_lines = self.auto_squeeze_min_lines
     253              # First, a very quick check to skip very short texts.
     254              if len(s) < auto_squeeze_min_lines:
     255                  return write(s, tags)
     256              # Now the full line-count check.
     257              numoflines = self.count_lines(s)
     258              if numoflines < auto_squeeze_min_lines:
     259                  return write(s, tags)
     260  
     261              # Create an ExpandingButton instance.
     262              expandingbutton = ExpandingButton(s, tags, numoflines, self)
     263  
     264              # Insert the ExpandingButton into the Text widget.
     265              text.mark_gravity("iomark", tk.RIGHT)
     266              text.window_create("iomark", window=expandingbutton,
     267                                 padx=3, pady=5)
     268              text.see("iomark")
     269              text.update()
     270              text.mark_gravity("iomark", tk.LEFT)
     271  
     272              # Add the ExpandingButton to the Squeezer's list.
     273              self.expandingbuttons.append(expandingbutton)
     274  
     275          editwin.write = mywrite
     276  
     277      def count_lines(self, s):
     278          """Count the number of lines in a given text.
     279  
     280          Before calculation, the tab width and line length of the text are
     281          fetched, so that up-to-date values are used.
     282  
     283          Lines are counted as if the string was wrapped so that lines are never
     284          over linewidth characters long.
     285  
     286          Tabs are considered tabwidth characters long.
     287          """
     288          return count_lines_with_wrapping(s, self.editwin.width)
     289  
     290      def squeeze_current_text(self):
     291          """Squeeze the text block where the insertion cursor is.
     292  
     293          If the cursor is not in a squeezable block of text, give the
     294          user a small warning and do nothing.
     295          """
     296          # Set tag_name to the first valid tag found on the "insert" cursor.
     297          tag_names = self.text.tag_names(tk.INSERT)
     298          for tag_name in ("stdout", "stderr"):
     299              if tag_name in tag_names:
     300                  break
     301          else:
     302              # The insert cursor doesn't have a "stdout" or "stderr" tag.
     303              self.text.bell()
     304              return "break"
     305  
     306          # Find the range to squeeze.
     307          start, end = self.text.tag_prevrange(tag_name, tk.INSERT + "+1c")
     308          s = self.text.get(start, end)
     309  
     310          # If the last char is a newline, remove it from the range.
     311          if len(s) > 0 and s[-1] == '\n':
     312              end = self.text.index("%s-1c" % end)
     313              s = s[:-1]
     314  
     315          # Delete the text.
     316          self.base_text.delete(start, end)
     317  
     318          # Prepare an ExpandingButton.
     319          numoflines = self.count_lines(s)
     320          expandingbutton = ExpandingButton(s, tag_name, numoflines, self)
     321  
     322          # insert the ExpandingButton to the Text
     323          self.text.window_create(start, window=expandingbutton,
     324                                  padx=3, pady=5)
     325  
     326          # Insert the ExpandingButton to the list of ExpandingButtons,
     327          # while keeping the list ordered according to the position of
     328          # the buttons in the Text widget.
     329          i = len(self.expandingbuttons)
     330          while i > 0 and self.text.compare(self.expandingbuttons[i-1],
     331                                            ">", expandingbutton):
     332              i -= 1
     333          self.expandingbuttons.insert(i, expandingbutton)
     334  
     335          return "break"
     336  
     337  
     338  Squeezer.reload()
     339  
     340  
     341  if __name__ == "__main__":
     342      from unittest import main
     343      main('idlelib.idle_test.test_squeezer', verbosity=2, exit=False)
     344  
     345      # Add htest.