diff -r 021dd3c65198 Lib/idlelib/Squeezer.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Lib/idlelib/Squeezer.py Tue Feb 11 23:55:53 2014 +0200 @@ -0,0 +1,380 @@ +"""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-cliking this button will remove it and insert the original text instead. +Middle-clicking will copy the text to the clipboard. If a preview command +is configured, right-clicking will open the text in an external viewing app +using that command. + +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 os +import re +import tempfile + +import tkinter as tk +from tkinter.font import Font + +from idlelib.PyShell import PyShell +from idlelib.configHandler import idleConf +from idlelib.ToolTip import ToolTip + + +def _add_to_rmenu(editwin, specs): + """Utility func: Add specs to the right-click menu of the given editwin.""" + # Important: don't use += or .append() here!!! + # rmenu_specs has a default value set as a class attribute, so we must be + # sure to create an instance attribute here, without changing the class + # attribute. + editwin.rmenu_specs = editwin.rmenu_specs + specs + + +def count_lines_with_wrapping(s, linewidth=80, tabwidth=8): + """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. + """ + 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': + 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 + + # avoid divmod(-1, linewidth) + if current_column > 0: + # If the length 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 doing divmod, and later add 1 to the column to + # compensate. + lines, column = divmod(current_column - 1, linewidth) + linecount += lines + current_column = column + 1 + + # 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: + # the text ended with a newline; don't count an extra line after it + linecount -= 1 + + return linecount + + +# define the extension's classes + +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 preview the original text in an + external application. + + 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.squeezer = squeezer + self.editwin = editwin = squeezer.editwin + self.text = text = editwin.text + + # the base Text widget of the PyShell object, used to change text + # before the iomark + self.base_text = editwin.per.bottom + + preview_command_defined = bool(self.squeezer.get_preview_command()) + + button_text = "Squeezed text (%d lines)." % numoflines + tk.Button.__init__(self, text, text=button_text, + background="#FFFFC0", activebackground="#FFFFE0") + + if self.squeezer.get_show_tooltip(): + button_tooltip_text = "Double-click to expand, middle-click to copy" + if preview_command_defined: + button_tooltip_text += ", right-click to preview." + else: + button_tooltip_text += "." + ToolTip(self, button_tooltip_text, + delay=self.squeezer.get_tooltip_delay()) + + self.bind("", self.expand) + self.bind("", self.copy) + if preview_command_defined: + self.bind("", self.preview) + self.selection_handle( + lambda offset, length: s[int(offset):int(offset) + int(length)]) + + def expand(self, event): + """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. + """ + 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): + """copy event handler + + Copy the original text to the clipboard. + """ + self.clipboard_clear() + self.clipboard_append(self.s) + + def preview(self, event): + """preview event handler + + View the original text in an external application, as configured in + the Squeezer instance. + """ + f = tempfile.NamedTemporaryFile(mode='w', suffix="longidletext", + delete=False) + fn = f.name + f.write(self.s) + f.close() + os.system(self.squeezer.get_preview_command() % {"fn": fn}) + + +class Squeezer: + """An IDLE extension for "squeezing" long texts into a simple button.""" + @classmethod + def get_auto_squeeze_min_lines(cls): + return idleConf.GetOption( + "extensions", "Squeezer", "auto-squeeze-min-lines", + type="int", default=30, + ) + + # allow configuring different external viewers for different platforms + _PREVIEW_COMMAND_CONFIG_PARAM_NAME = \ + "preview-command-" + ("win" if os.name == "nt" else os.name) + + @classmethod + def get_preview_command(cls): + return idleConf.GetOption( + "extensions", "Squeezer", cls._PREVIEW_COMMAND_CONFIG_PARAM_NAME, + default="", raw=True, + ) + + @classmethod + def get_show_tooltip(cls): + return idleConf.GetOption( + "extensions", "Squeezer", "show-tooltip", + type="bool", default=True, + ) + + @classmethod + def get_tooltip_delay(cls): + return idleConf.GetOption( + "extensions", "Squeezer", "tooltip-delay", + type="int", default=0, + ) + + menudefs = [ + ('edit', [ + None, # Separator + ("Expand last squeezed text", "<>"), + # The following is commented out on purpose; it is conditionally + # added immediately after the class definition (see below) if a + # preview command is defined. + # ("Preview last squeezed text", "<>") + ]), + ] + + def __init__(self, editwin): + 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. But Squeezer deliberately needs to make such + # changes. + self.base_text = editwin.per.bottom + + self.expandingbuttons = [] + if isinstance(editwin, PyShell): + # If we get a PyShell instance, replace its 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 + numoflines = self.count_lines(s) + if numoflines < self.get_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 + + # Add squeeze-current-text to the right-click menu + text.bind("<>", + self.squeeze_current_text_event) + _add_to_rmenu(editwin, [("Squeeze current text", + "<>")]) + + 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. + """ + # Tab width is configurable + tabwidth = self.editwin.get_tk_tabwidth() + + # Get the Text widget's size + linewidth = self.editwin.text.winfo_width() + # Deduct the border and padding + linewidth -= 2*sum([int(self.editwin.text.cget(opt)) + for opt in ('border', 'padx')]) + + # Get the Text widget's font + font = Font(self.editwin.text, name=self.editwin.text.cget('font')) + # Divide the size of the Text widget by the font's width. + # According to Tk8.5 docs, the Text widget's width is set + # according to the width of its font's '0' (zero) character, + # so we will use this as an approximation. + # see: http://www.tcl.tk/man/tcl8.5/TkCmd/text.htm#M-width + linewidth //= font.measure('0') + + return count_lines_with_wrapping(s, linewidth, tabwidth) + + def expand_last_squeezed_event(self, event): + """expand-last-squeezed event handler + + Expand the last squeezed text in the Text widget. + + If there is no such squeezed text, give the user a small warning and + do nothing. + """ + if len(self.expandingbuttons) > 0: + self.expandingbuttons[-1].expand(event) + else: + self.text.bell() + return "break" + + def preview_last_squeezed_event(self, event): + """preview-last-squeezed event handler + + Preview the last squeezed text in the Text widget. + + If there is no such squeezed text, give the user a small warning and + do nothing. + """ + if self.get_preview_command() and len(self.expandingbuttons) > 0: + self.expandingbuttons[-1].preview(event) + else: + self.text.bell() + return "break" + + 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" + +# Add a "Preview last squeezed text" option to the right-click menu, but only +# if a preview command is configured. +if Squeezer.get_preview_command(): + Squeezer.menudefs[0][1].append(("Preview last squeezed text", + "<>")) diff -r 021dd3c65198 Lib/idlelib/ToolTip.py --- a/Lib/idlelib/ToolTip.py Tue Feb 11 10:16:16 2014 -0500 +++ b/Lib/idlelib/ToolTip.py Tue Feb 11 23:55:53 2014 +0200 @@ -7,8 +7,9 @@ class ToolTipBase: - def __init__(self, button): + def __init__(self, button, delay=1500): self.button = button + self.delay = delay self.tipwindow = None self.id = None self.x = self.y = 0 @@ -25,7 +26,7 @@ def schedule(self): self.unschedule() - self.id = self.button.after(1500, self.showtip) + self.id = self.button.after(self.delay, self.showtip) def unschedule(self): id = self.id @@ -60,15 +61,15 @@ tw.destroy() class ToolTip(ToolTipBase): - def __init__(self, button, text): - ToolTipBase.__init__(self, button) + def __init__(self, button, text, delay=1500): + ToolTipBase.__init__(self, button, delay=delay) self.text = text def showcontents(self): ToolTipBase.showcontents(self, self.text) class ListboxToolTip(ToolTipBase): - def __init__(self, button, items): - ToolTipBase.__init__(self, button) + def __init__(self, button, items, delay=1500): + ToolTipBase.__init__(self, button, delay=delay) self.items = items def showcontents(self): listbox = Listbox(self.tipwindow, background="#ffffe0") diff -r 021dd3c65198 Lib/idlelib/config-extensions.def --- a/Lib/idlelib/config-extensions.def Tue Feb 11 10:16:16 2014 -0500 +++ b/Lib/idlelib/config-extensions.def Tue Feb 11 23:55:53 2014 +0200 @@ -94,3 +94,15 @@ enable_shell=0 enable_editor=1 +[Squeezer] +enable=1 +enable_editor=0 +auto-squeeze-min-lines=30 +show-tooltip=True +tooltip-delay=0 +preview-command-win= +preview-command-posix= +[Squeezer_bindings] +expand-last-squeezed= +preview-last-squeezed= +squeeze-current-text= diff -r 021dd3c65198 Lib/idlelib/idle_test/test_squeezer.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Lib/idlelib/idle_test/test_squeezer.py Tue Feb 11 23:55:53 2014 +0200 @@ -0,0 +1,710 @@ +import os +from collections import namedtuple +from tkinter import Text +import unittest +from unittest.mock import Mock, NonCallableMagicMock, patch, sentinel, ANY +from test.support import requires + +from idlelib.Squeezer import Squeezer, count_lines_with_wrapping, \ + ExpandingButton +from idlelib.PyShell import PyShell + + +SENTINEL_VALUE = sentinel.SENTINEL_VALUE + + +class TestCountLines(unittest.TestCase): + """tests for the count_lines_with_wrapping function""" + def check(self, expected, text, linewidth, tabwidth): + return self.assertEquals( + expected, + count_lines_with_wrapping(text, linewidth, tabwidth), + ) + + def test_count_empty(self): + """test with an empty string""" + self.assertEquals(count_lines_with_wrapping(""), 0) + + def test_count_begins_with_empty_line(self): + """test with a string which begins with a newline""" + self.assertEquals(count_lines_with_wrapping("\ntext"), 2) + + def test_count_ends_with_empty_line(self): + """test with a string which ends with a newline""" + self.assertEquals(count_lines_with_wrapping("text\n"), 1) + + def test_count_several_lines(self): + """test with several lines of text""" + self.assertEquals(count_lines_with_wrapping("1\n2\n3\n"), 3) + + def test_tab_width(self): + """test with various tab widths and line widths""" + self.check(expected=1, text='\t' * 1, linewidth=8, tabwidth=4) + self.check(expected=1, text='\t' * 2, linewidth=8, tabwidth=4) + self.check(expected=2, text='\t' * 3, linewidth=8, tabwidth=4) + self.check(expected=2, text='\t' * 4, linewidth=8, tabwidth=4) + self.check(expected=3, text='\t' * 5, linewidth=8, tabwidth=4) + + # test longer lines and various tab widths + self.check(expected=4, text='\t' * 10, linewidth=12, tabwidth=4) + self.check(expected=10, text='\t' * 10, linewidth=12, tabwidth=8) + self.check(expected=2, text='\t' * 4, linewidth=10, tabwidth=3) + + # test tabwidth=1 + self.check(expected=2, text='\t' * 9, linewidth=5, tabwidth=1) + self.check(expected=2, text='\t' * 10, linewidth=5, tabwidth=1) + self.check(expected=3, text='\t' * 11, linewidth=5, tabwidth=1) + + # test for off-by-one errors + self.check(expected=2, text='\t' * 6, linewidth=12, tabwidth=4) + self.check(expected=3, text='\t' * 6, linewidth=11, tabwidth=4) + self.check(expected=2, text='\t' * 6, linewidth=13, tabwidth=4) + + +class TestSqueezer(unittest.TestCase): + """tests for the Squeezer class""" + def make_mock_editor_window(self): + """create a mock EditorWindow instance""" + editwin = NonCallableMagicMock() + # isinstance(editwin, PyShell) must be true for Squeezer to enable + # auto-squeezing; in practice this will always be true + editwin.__class__ = PyShell + return editwin + + def make_squeezer_instance(self, editor_window=None): + """create an actual Squeezer instance with a mock EditorWindow""" + if editor_window is None: + editor_window = self.make_mock_editor_window() + return Squeezer(editor_window) + + def test_count_lines(self): + """test Squeezer.count_lines() with various inputs + + This checks that Squeezer.count_lines() calls the + count_lines_with_wrapping() function with the appropriate parameters. + """ + for tabwidth, linewidth in [(4, 80), (1, 79), (8, 80), (3, 120)]: + self._test_count_lines_helper(linewidth=linewidth, + tabwidth=tabwidth) + + def _prepare_mock_editwin_for_count_lines(self, editwin, + linewidth, tabwidth): + """prepare a mock EditorWindow object so Squeezer.count_lines can run""" + CHAR_WIDTH = 10 + BORDER_WIDTH = 2 + PADDING_WIDTH = 1 + + # Prepare all the required functionality on the mock EditorWindow object + # so that the calculations in Squeezer.count_lines() can run. + editwin.get_tk_tabwidth.return_value = tabwidth + editwin.text.winfo_width.return_value = \ + linewidth * CHAR_WIDTH + 2 * (BORDER_WIDTH + PADDING_WIDTH) + text_opts = { + 'border': BORDER_WIDTH, + 'padx': PADDING_WIDTH, + 'font': None, + } + editwin.text.cget = lambda opt: text_opts[opt] + + # monkey-path tkinter.font.Font with a mock object, so that + # Font.measure('0') returns CHAR_WIDTH + mock_font = Mock() + def measure(char): + if char == '0': + return CHAR_WIDTH + raise ValueError("measure should only be called on '0'!") + mock_font.return_value.measure = measure + patcher = patch('idlelib.Squeezer.Font', mock_font) + patcher.start() + self.addCleanup(patcher.stop) + + def _test_count_lines_helper(self, linewidth, tabwidth): + """helper for test_count_lines""" + editwin = self.make_mock_editor_window() + self._prepare_mock_editwin_for_count_lines(editwin, linewidth, tabwidth) + squeezer = self.make_squeezer_instance(editwin) + + mock_count_lines = Mock(return_value=SENTINEL_VALUE) + text = 'TEXT' + with patch('idlelib.Squeezer.count_lines_with_wrapping', + mock_count_lines): + self.assertIs(squeezer.count_lines(text), SENTINEL_VALUE) + mock_count_lines.assert_called_with(text, linewidth, tabwidth) + + def test_init(self): + """test the creation of Squeezer instances""" + editwin = self.make_mock_editor_window() + editwin.rmenu_specs = [] + squeezer = self.make_squeezer_instance(editwin) + self.assertIs(squeezer.editwin, editwin) + self.assertEquals(squeezer.expandingbuttons, []) + self.assertEquals(squeezer.text.bind.call_count, 1) + squeezer.text.bind.assert_called_with( + '<>', squeezer.squeeze_current_text_event) + self.assertEquals(editwin.rmenu_specs, [ + ("Squeeze current text", "<>"), + ]) + + def test_write_no_tags(self): + """test Squeezer's overriding of the EditorWindow's write() method""" + editwin = self.make_mock_editor_window() + for text in ['', 'TEXT', 'LONG TEXT' * 1000, 'MANY_LINES\n' * 100]: + editwin.write = orig_write = Mock(return_value=SENTINEL_VALUE) + squeezer = self.make_squeezer_instance(editwin) + + self.assertEquals(squeezer.editwin.write(text, ()), SENTINEL_VALUE) + self.assertEquals(orig_write.call_count, 1) + orig_write.assert_called_with(text, ()) + self.assertEquals(len(squeezer.expandingbuttons), 0) + + def test_write_not_stdout(self): + """test Squeezer's overriding of the EditorWindow's write() method""" + for text in ['', 'TEXT', 'LONG TEXT' * 1000, 'MANY_LINES\n' * 100]: + editwin = self.make_mock_editor_window() + editwin.write.return_value = SENTINEL_VALUE + orig_write = editwin.write + squeezer = self.make_squeezer_instance(editwin) + + self.assertEquals(squeezer.editwin.write(text, "stderr"), + SENTINEL_VALUE) + self.assertEquals(orig_write.call_count, 1) + orig_write.assert_called_with(text, "stderr") + self.assertEquals(len(squeezer.expandingbuttons), 0) + + def test_write_stdout(self): + """test Squeezer's overriding of the EditorWindow's write() method""" + editwin = self.make_mock_editor_window() + self._prepare_mock_editwin_for_count_lines(editwin, + linewidth=80, tabwidth=8) + + for text in ['', 'TEXT']: + editwin.write = orig_write = Mock(return_value=SENTINEL_VALUE) + squeezer = self.make_squeezer_instance(editwin) + squeezer.get_auto_squeeze_min_lines = Mock(return_value=30) + + self.assertEquals(squeezer.editwin.write(text, "stdout"), + SENTINEL_VALUE) + self.assertEquals(orig_write.call_count, 1) + orig_write.assert_called_with(text, "stdout") + self.assertEquals(len(squeezer.expandingbuttons), 0) + + for text in ['LONG TEXT' * 1000, 'MANY_LINES\n' * 100]: + editwin.write = orig_write = Mock(return_value=SENTINEL_VALUE) + squeezer = self.make_squeezer_instance(editwin) + squeezer.get_auto_squeeze_min_lines = Mock(return_value=30) + + self.assertEquals(squeezer.editwin.write(text, "stdout"), None) + self.assertEquals(orig_write.call_count, 0) + self.assertEquals(len(squeezer.expandingbuttons), 1) + + def test_expand_last_squeezed_event_no_squeezed(self): + """test the expand_last_squeezed event""" + # The tested scenario: There are no squeezed texts, therefore there + # are no ExpandingButton instances. The expand_last_squeezed event + # is called and should fail (i.e. call squeezer.text.bell()). + editwin = self.make_mock_editor_window() + squeezer = self.make_squeezer_instance(editwin) + + retval = squeezer.expand_last_squeezed_event(event=Mock()) + self.assertEquals(retval, "break") + self.assertEquals(squeezer.text.bell.call_count, 1) + + def test_expand_last_squeezed_event(self): + """test the expand_last_squeezed event""" + # The tested scenario: There are two squeezed texts, therefore there + # are two ExpandingButton instances. The expand_last_squeezed event + # is called three times. The first time should expand the second + # ExpandingButton; the second time should expand the first + # ExpandingButton; the third time should fail (i.e. call + # squeezer.text.bell()). + editwin = self.make_mock_editor_window() + squeezer = self.make_squeezer_instance(editwin) + mock_expandingbutton1 = Mock() + mock_expandingbutton2 = Mock() + squeezer.expandingbuttons = [mock_expandingbutton1, + mock_expandingbutton2] + + # check that the second expanding button is expanded + retval = squeezer.expand_last_squeezed_event(event=SENTINEL_VALUE) + self.assertEquals(retval, "break") + self.assertEquals(squeezer.text.bell.call_count, 0) + self.assertEquals(mock_expandingbutton1.expand.call_count, 0) + self.assertEquals(mock_expandingbutton2.expand.call_count, 1) + mock_expandingbutton2.expand.assert_called_with(SENTINEL_VALUE) + + # normally the expanded ExpandingButton would remove itself from + # squeezer.expandingbuttons, but we used a mock instead + squeezer.expandingbuttons.remove(mock_expandingbutton2) + + # check that the first expanding button is expanded + retval = squeezer.expand_last_squeezed_event(event=SENTINEL_VALUE) + self.assertEquals(retval, "break") + self.assertEquals(squeezer.text.bell.call_count, 0) + self.assertEquals(mock_expandingbutton1.expand.call_count, 1) + self.assertEquals(mock_expandingbutton2.expand.call_count, 1) + mock_expandingbutton1.expand.assert_called_with(SENTINEL_VALUE) + + # normally the expanded ExpandingButton would remove itself from + # squeezer.expandingbuttons, but we used a mock instead + squeezer.expandingbuttons.remove(mock_expandingbutton1) + + # no more expanding buttons -- check that squeezer.text.bell() is called + retval = squeezer.expand_last_squeezed_event(event=Mock()) + self.assertEquals(retval, "break") + self.assertEquals(squeezer.text.bell.call_count, 1) + + def test_preview_last_squeezed_event_no_squeezed(self): + """test the preview_last_squeezed event""" + # The tested scenario: There are no squeezed texts, therefore there + # are no ExpandingButton instances. The preview_last_squeezed event + # is called and should fail (i.e. call squeezer.text.bell()). + editwin = self.make_mock_editor_window() + squeezer = self.make_squeezer_instance(editwin) + squeezer.get_preview_command = Mock(return_value='notepad.exe %(fn)s') + + retval = squeezer.preview_last_squeezed_event(event=Mock()) + self.assertEquals(retval, "break") + self.assertEquals(squeezer.text.bell.call_count, 1) + + def test_preview_last_squeezed_event_no_preview_command(self): + """test the preview_last_squeezed event""" + # The tested scenario: There is one squeezed text, therefore there + # is one ExpandingButton instance. However, no preview command has been + # configured. The preview_last_squeezed event is called and should fail + # (i.e. call squeezer.text.bell()). + editwin = self.make_mock_editor_window() + squeezer = self.make_squeezer_instance(editwin) + squeezer.get_preview_command = Mock(return_value='') + mock_expandingbutton = Mock() + squeezer.expandingbuttons = [mock_expandingbutton] + + retval = squeezer.preview_last_squeezed_event(event=Mock()) + self.assertEquals(retval, "break") + self.assertEquals(squeezer.text.bell.call_count, 1) + + def test_preview_last_squeezed_event(self): + """test the preview_last_squeezed event""" + # The tested scenario: There are two squeezed texts, therefore there + # are two ExpandingButton instances. The preview_last_squeezed event + # is called twice. Both times should call the preview() method of the + # second ExpandingButton. + editwin = self.make_mock_editor_window() + squeezer = self.make_squeezer_instance(editwin) + squeezer.get_preview_command = Mock(return_value='notepad.exe %(fn)s') + mock_expandingbutton1 = Mock() + mock_expandingbutton2 = Mock() + squeezer.expandingbuttons = [mock_expandingbutton1, + mock_expandingbutton2] + + # check that the second expanding button is previewed + retval = squeezer.preview_last_squeezed_event(event=SENTINEL_VALUE) + self.assertEquals(retval, "break") + self.assertEquals(squeezer.text.bell.call_count, 0) + self.assertEquals(mock_expandingbutton1.preview.call_count, 0) + self.assertEquals(mock_expandingbutton2.preview.call_count, 1) + mock_expandingbutton2.preview.assert_called_with(SENTINEL_VALUE) + + def test_auto_squeeze(self): + """test that the auto-squeezing creates an ExpandingButton properly""" + requires('gui') + text_widget = Text() + text_widget.mark_set("iomark", "1.0") + + editwin = self.make_mock_editor_window() + editwin.text = text_widget + squeezer = self.make_squeezer_instance(editwin) + squeezer.get_auto_squeeze_min_lines = Mock(return_value=5) + squeezer.count_lines = Mock(return_value=6) + + editwin.write('TEXT\n'*6, "stdout") + self.assertEquals(text_widget.get('1.0', 'end'), '\n') + self.assertEquals(len(squeezer.expandingbuttons), 1) + + def test_squeeze_current_text_event(self): + """test the squeeze_current_text event""" + requires('gui') + + # squeezing text should work for both stdout and stderr + for tag_name in "stdout", "stderr": + text_widget = Text() + text_widget.mark_set("iomark", "1.0") + + editwin = self.make_mock_editor_window() + editwin.text = editwin.per.bottom = text_widget + squeezer = self.make_squeezer_instance(editwin) + squeezer.count_lines = Mock(return_value=6) + + # prepare some text in the Text widget + text_widget.insert("1.0", "SOME\nTEXT\n", tag_name) + text_widget.mark_set("insert", "1.0") + self.assertEquals(text_widget.get('1.0', 'end'), 'SOME\nTEXT\n\n') + + self.assertEquals(len(squeezer.expandingbuttons), 0) + + # test squeezing the current text + retval = squeezer.squeeze_current_text_event(event=Mock()) + self.assertEquals(retval, "break") + self.assertEquals(text_widget.get('1.0', 'end'), '\n\n') + self.assertEquals(len(squeezer.expandingbuttons), 1) + self.assertEquals(squeezer.expandingbuttons[0].s, 'SOME\nTEXT') + + # test that expanding the squeezed text works and afterwards the + # Text widget contains the original text + squeezer.expandingbuttons[0].expand(event=Mock()) + self.assertEquals(text_widget.get('1.0', 'end'), 'SOME\nTEXT\n\n') + self.assertEquals(len(squeezer.expandingbuttons), 0) + + def test_squeeze_current_text_event_no_allowed_tags(self): + """test that the event doesn't squeeze text without a relevant tag""" + requires('gui') + + text_widget = Text() + text_widget.mark_set("iomark", "1.0") + + editwin = self.make_mock_editor_window() + editwin.text = editwin.per.bottom = text_widget + squeezer = self.make_squeezer_instance(editwin) + squeezer.count_lines = Mock(return_value=6) + + # prepare some text in the Text widget + text_widget.insert("1.0", "SOME\nTEXT\n", "TAG") + text_widget.mark_set("insert", "1.0") + self.assertEquals(text_widget.get('1.0', 'end'), 'SOME\nTEXT\n\n') + + self.assertEquals(len(squeezer.expandingbuttons), 0) + + # test squeezing the current text + retval = squeezer.squeeze_current_text_event(event=Mock()) + self.assertEquals(retval, "break") + self.assertEquals(text_widget.get('1.0', 'end'), 'SOME\nTEXT\n\n') + self.assertEquals(len(squeezer.expandingbuttons), 0) + + def test_squeeze_text_before_existing_squeezed_text(self): + """test squeezing text before existing squeezed text""" + requires('gui') + + text_widget = Text() + text_widget.mark_set("iomark", "1.0") + + editwin = self.make_mock_editor_window() + editwin.text = editwin.per.bottom = text_widget + squeezer = self.make_squeezer_instance(editwin) + squeezer.count_lines = Mock(return_value=6) + + # prepare some text in the Text widget and squeeze it + text_widget.insert("1.0", "SOME\nTEXT\n", "stdout") + text_widget.mark_set("insert", "1.0") + squeezer.squeeze_current_text_event(event=Mock()) + self.assertEquals(len(squeezer.expandingbuttons), 1) + + # test squeezing the current text + text_widget.insert("1.0", "MORE\nSTUFF\n", "stdout") + text_widget.mark_set("insert", "1.0") + retval = squeezer.squeeze_current_text_event(event=Mock()) + self.assertEquals(retval, "break") + self.assertEquals(text_widget.get('1.0', 'end'), '\n\n\n') + self.assertEquals(len(squeezer.expandingbuttons), 2) + self.assertTrue(text_widget.compare( + squeezer.expandingbuttons[0], + '<', + squeezer.expandingbuttons[1], + )) + + GetOptionSignature = namedtuple('GetOptionSignature', + 'configType section option default type warn_on_default raw') + @classmethod + def _make_sig(cls, configType, section, option, default=sentinel.NOT_GIVEN, + type=sentinel.NOT_GIVEN, + warn_on_default=sentinel.NOT_GIVEN, + raw=sentinel.NOT_GIVEN): + return cls.GetOptionSignature(configType, section, option, default, + type, warn_on_default, raw) + + @classmethod + def get_GetOption_signature(cls, mock_call_obj): + args, kwargs = mock_call_obj[-2:] + return cls._make_sig(*args, **kwargs) + + def test_get_auto_squeeze_min_lines(self): + """test the auto-squeeze-min-lines config getter""" + with patch('idlelib.configHandler.idleConf.GetOption') as MockGetOption: + MockGetOption.return_value = SENTINEL_VALUE + retval = Squeezer.get_auto_squeeze_min_lines() + + self.assertEquals(retval, SENTINEL_VALUE) + self.assertEquals(MockGetOption.call_count, 1) + sig = self.get_GetOption_signature(MockGetOption.call_args) + self.assertSequenceEqual( + (sig.configType, sig.section, sig.option), + ("extensions", "Squeezer", "auto-squeeze-min-lines"), + ) + self.assertEquals(sig.type, "int") + self.assertNotEquals(sig.default, sentinel.NOT_GIVEN) + + def test_get_preview_command(self): + """test the preview-command config getter""" + with patch('idlelib.configHandler.idleConf.GetOption') as MockGetOption: + MockGetOption.return_value = SENTINEL_VALUE + retval = Squeezer.get_preview_command() + + self.assertEquals(retval, SENTINEL_VALUE) + self.assertEquals(MockGetOption.call_count, 1) + sig = self.get_GetOption_signature(MockGetOption.call_args) + self.assertSequenceEqual( + (sig.configType, sig.section, sig.option), + ("extensions", "Squeezer", + Squeezer._PREVIEW_COMMAND_CONFIG_PARAM_NAME), + ) + self.assertTrue(sig.raw) + self.assertNotEquals(sig.default, sentinel.NOT_GIVEN) + + def test_get_show_tooltip(self): + """test the show-tooltip config getter""" + with patch('idlelib.configHandler.idleConf.GetOption') as MockGetOption: + MockGetOption.return_value = SENTINEL_VALUE + retval = Squeezer.get_show_tooltip() + + self.assertEquals(retval, SENTINEL_VALUE) + self.assertEquals(MockGetOption.call_count, 1) + sig = self.get_GetOption_signature(MockGetOption.call_args) + self.assertSequenceEqual( + (sig.configType, sig.section, sig.option), + ("extensions", "Squeezer", "show-tooltip"), + ) + self.assertEquals(sig.type, "bool") + self.assertNotEquals(sig.default, sentinel.NOT_GIVEN) + + def test_get_tooltip_delay(self): + """test the tooltip-delay config getter""" + with patch('idlelib.configHandler.idleConf.GetOption') as MockGetOption: + MockGetOption.return_value = SENTINEL_VALUE + retval = Squeezer.get_tooltip_delay() + + self.assertEquals(retval, SENTINEL_VALUE) + self.assertEquals(MockGetOption.call_count, 1) + sig = self.get_GetOption_signature(MockGetOption.call_args) + self.assertSequenceEqual( + (sig.configType, sig.section, sig.option), + ("extensions", "Squeezer", "tooltip-delay"), + ) + self.assertEquals(sig.type, "int") + self.assertNotEquals(sig.default, sentinel.NOT_GIVEN) + + def test_conditional_add_preview_last_squeezed_text_to_edit_menu(self): + """test conditionally adding preview-last-squeezed to the edit menu""" + import importlib + import idlelib.Squeezer + + # cleanup -- make sure to reload idlelib.Squeezer, since ths test + # messes around with it a bit + self.addCleanup(importlib.reload, idlelib.Squeezer) + + preview_last_squeezed_menu_item = \ + ("Preview last squeezed text", "<>") + + # We can't override idlelib.Squeezer.Squeezer.get_preview_command() + # in time, since what we want to test happens at module load time, + # and such a change can only be done once the module has been loaded. + # Instead, we'll patch idlelib.configHandler.idleConf.GetOption which + # is used by get_preview_command(). + with patch('idlelib.configHandler.idleConf.GetOption') as MockGetOption: + # First, load the module with no preview command defined, and check + # that the preview-last-squeezed option is not added to the Edit + # menu. + MockGetOption.return_value = '' + importlib.reload(idlelib.Squeezer) + edit_menu = dict(idlelib.Squeezer.Squeezer.menudefs)['edit'] + self.assertNotIn(preview_last_squeezed_menu_item, edit_menu) + + # save the length of the edit menu spec, for comparison later + edit_menu_len_without_preview_last = len(edit_menu) + + # Second, load the module with a preview command defined, and check + # that the preview-last-squeezed option is indeed added to the Edit + # menu. + MockGetOption.return_value = 'notepad.exe %(fn)s' + importlib.reload(idlelib.Squeezer) + edit_menu = dict(idlelib.Squeezer.Squeezer.menudefs)['edit'] + self.assertEquals(edit_menu[-1], preview_last_squeezed_menu_item) + self.assertEquals(len(edit_menu), edit_menu_len_without_preview_last + 1) + + +class TestExpandingButton(unittest.TestCase): + """tests for the ExpandingButton class""" + # In these tests the squeezer instance is a mock, but actual tkinter + # Text and Button instances are created. + def make_mock_squeezer(self): + """helper for tests""" + requires('gui') + squeezer = Mock() + squeezer.editwin.text = Text() + + # Set default values for the configuration settings + squeezer.get_max_num_of_lines = Mock(return_value=30) + squeezer.get_preview_command = Mock(return_value='') + squeezer.get_show_tooltip = Mock(return_value=False) + squeezer.get_tooltip_delay = Mock(return_value=1500) + return squeezer + + @patch('idlelib.Squeezer.ToolTip') + def test_init_no_preview_command_nor_tooltip(self, MockToolTip): + """Test the simplest creation of an ExpandingButton""" + squeezer = self.make_mock_squeezer() + squeezer.get_show_tooltip.return_value = False + squeezer.get_preview_command.return_value = '' + text_widget = squeezer.editwin.text + + expandingbutton = ExpandingButton('TEXT', 'TAGS', 30, squeezer) + self.assertEquals(expandingbutton.s, 'TEXT') + + # check that the underlying tkinter.Button is properly configured + self.assertEquals(expandingbutton.master, text_widget) + self.assertTrue('30 lines' in expandingbutton.cget('text')) + + # check that the text widget still contains no text + self.assertEquals(text_widget.get('1.0', 'end'), '\n') + + # check that no tooltip was created + self.assertEquals(MockToolTip.call_count, 0) + + def test_bindings_with_preview_command(self): + """test tooltip creation with a preview command configured""" + squeezer = self.make_mock_squeezer() + squeezer.get_preview_command.return_value = 'notepad.exe %(fn)s' + expandingbutton = ExpandingButton('TEXT', 'TAGS', 30, squeezer) + + # check that when a preview command is configured, an event is bound + # on the button for middle-click + self.assertIn('', expandingbutton.bind()) + self.assertIn('', expandingbutton.bind()) + self.assertIn('', expandingbutton.bind()) + + def test_bindings_without_preview_command(self): + """test tooltip creation without a preview command configured""" + squeezer = self.make_mock_squeezer() + squeezer.get_preview_command.return_value = '' + expandingbutton = ExpandingButton('TEXT', 'TAGS', 30, squeezer) + + # check button's event bindings: double-click, right-click, middle-click + self.assertIn('', expandingbutton.bind()) + self.assertIn('', expandingbutton.bind()) + self.assertNotIn('', expandingbutton.bind()) + + @patch('idlelib.Squeezer.ToolTip') + def test_init_tooltip_with_preview_command(self, MockToolTip): + """test tooltip creation with a preview command configured""" + squeezer = self.make_mock_squeezer() + squeezer.get_show_tooltip.return_value = True + squeezer.get_tooltip_delay.return_value = SENTINEL_VALUE + squeezer.get_preview_command.return_value = 'notepad.exe %(fn)s' + expandingbutton = ExpandingButton('TEXT', 'TAGS', 30, squeezer) + + # check that ToolTip was called once, with appropriate values + self.assertEquals(MockToolTip.call_count, 1) + MockToolTip.assert_called_with(expandingbutton, ANY, + delay=SENTINEL_VALUE) + + # check that 'right-click' appears in the tooltip text, since we + # configured a non-empty preview command + tooltip_text = MockToolTip.call_args[0][1] + self.assertIn('right-click', tooltip_text.lower()) + + @patch('idlelib.Squeezer.ToolTip') + def test_init_tooltip_without_preview_command(self, MockToolTip): + """test tooltip creation without a preview command configured""" + squeezer = self.make_mock_squeezer() + squeezer.get_show_tooltip.return_value = True + squeezer.get_tooltip_delay.return_value = SENTINEL_VALUE + squeezer.get_preview_command.return_value = '' + expandingbutton = ExpandingButton('TEXT', 'TAGS', 30, squeezer) + + # check that ToolTip was called once, with appropriate values + self.assertEquals(MockToolTip.call_count, 1) + MockToolTip.assert_called_with(expandingbutton, ANY, + delay=SENTINEL_VALUE) + + # check that 'right-click' doesn't appear in the tooltip text, since + # we configured an empty preview command + tooltip_text = MockToolTip.call_args[0][1] + self.assertNotIn('right-click', tooltip_text.lower()) + + def test_expand(self): + """test the expand event""" + squeezer = self.make_mock_squeezer() + expandingbutton = ExpandingButton('TEXT', 'TAGS', 30, squeezer) + + # insert the button into the text widget + # (this is normally done by the Squeezer class) + text_widget = expandingbutton.text + text_widget.window_create("1.0", window=expandingbutton) + + # set base_text to the text widget, so that changes are actually made + # to it (by ExpandingButton) and we can inspect these changes afterwards + expandingbutton.base_text = expandingbutton.text + + # trigger the expand event + retval = expandingbutton.expand(event=Mock()) + self.assertEquals(retval, None) + + # check that the text was inserting into the text widget + self.assertEquals(text_widget.get('1.0', 'end'), 'TEXT\n') + + # check that the 'TAGS' tag was set on the inserted text + text_end_index = text_widget.index('end-1c') + self.assertEquals(text_widget.get('1.0', text_end_index), 'TEXT') + self.assertEquals(text_widget.tag_nextrange('TAGS', '1.0'), + ('1.0', text_end_index)) + + # check that the button removed itself from squeezer.expandingbuttons + self.assertEquals(squeezer.expandingbuttons.remove.call_count, 1) + squeezer.expandingbuttons.remove.assert_called_with(expandingbutton) + + def test_copy(self): + """test the copy event""" + # testing with the actual clipboard proved problematic, so this test + # replaces the clipboard manipulation functions with mocks and checks + # that they are called appropriately + squeezer = self.make_mock_squeezer() + expandingbutton = ExpandingButton('TEXT', 'TAGS', 30, squeezer) + expandingbutton.clipboard_clear = Mock() + expandingbutton.clipboard_append = Mock() + + # trigger the copy event + retval = expandingbutton.copy(event=Mock()) + self.assertEquals(retval, None) + + # check that the expanding button called clipboard_clear() and + # clipboard_append('TEXT') once each + self.assertEquals(expandingbutton.clipboard_clear.call_count, 1) + self.assertEquals(expandingbutton.clipboard_append.call_count, 1) + expandingbutton.clipboard_append.assert_called_with('TEXT') + + def test_preview(self): + """test the preview event""" + squeezer = self.make_mock_squeezer() + squeezer.get_preview_command.return_value = 'FAKE_VIEWER_APP %(fn)s' + expandingbutton = ExpandingButton('TEXT', 'TAGS', 30, squeezer) + expandingbutton.selection_own = Mock() + + with patch('os.system') as mock_os_system: + # trigger the preview event + retval = expandingbutton.preview(event=Mock()) + self.assertEquals(retval, None) + + # check that the expanding button called 'os.system()' once + self.assertEquals(mock_os_system.call_count, 1) + + command = mock_os_system.call_args[0][0] + viewer, filename = command.split(' ', 1) + + # check that the command line was created using the configured + # preview command, and that a temporary file was actually created + self.assertEquals(viewer, 'FAKE_VIEWER_APP') + self.assertTrue(os.path.isfile(filename)) + + # cleanup - remove the temporary file after this test + self.addCleanup(os.remove, filename) + + # check that the temporary file contains the squeezed text + with open(filename, 'r') as f: + self.assertEquals(f.read(), 'TEXT')