From a7e130ae6e402db479733041b40470e5f0d3cd49 Mon Sep 17 00:00:00 2001 From: Tal Einat Date: Mon, 11 Jun 2018 13:41:52 +0300 Subject: [PATCH 01/16] bpo-1529353: update the Squeezer extension and its tests --- Lib/idlelib/config-extensions.def | 12 + Lib/idlelib/editor.py | 32 +- Lib/idlelib/idle_test/test_config.py | 15 +- Lib/idlelib/idle_test/test_squeezer.py | 725 +++++++++++++++++++++++++ Lib/idlelib/squeezer.py | 413 ++++++++++++++ Lib/idlelib/tooltip.py | 13 +- 6 files changed, 1190 insertions(+), 20 deletions(-) create mode 100644 Lib/idlelib/idle_test/test_squeezer.py create mode 100644 Lib/idlelib/squeezer.py diff --git a/Lib/idlelib/config-extensions.def b/Lib/idlelib/config-extensions.def index 7e23fb0a73d1d5..2756dab2ba4556 100644 --- a/Lib/idlelib/config-extensions.def +++ b/Lib/idlelib/config-extensions.def @@ -48,6 +48,18 @@ bell= True # # See config-keys.def for notes on specifying keys and extend.txt for # information on creating IDLE extensions. +[Squeezer] +enable= True +enable_editor= False +auto-squeeze-min-lines= 30 +show-tooltip= True +tooltip-delay= 500 +preview-command-win= notepad.exe {filepath} +preview-command-posix= +[Squeezer_bindings] +expand-last-squeezed= +preview-last-squeezed= +squeeze-current-text= # A fake extension for testing and example purposes. When enabled and # invoked, inserts or deletes z-text at beginning of every line. diff --git a/Lib/idlelib/editor.py b/Lib/idlelib/editor.py index 7c3f215e9f96a8..4005147cb6da11 100644 --- a/Lib/idlelib/editor.py +++ b/Lib/idlelib/editor.py @@ -1055,15 +1055,7 @@ def get_standard_extension_names(self): } def load_extension(self, name): - fname = self.extfiles.get(name, name) - try: - try: - mod = importlib.import_module('.' + fname, package=__package__) - except (ImportError, TypeError): - mod = importlib.import_module(fname) - except ImportError: - print("\nFailed to import extension: ", name) - raise + mod = self._load_extension_module(name) cls = getattr(mod, name) keydefs = idleConf.GetExtensionBindings(name) if hasattr(cls, "menudefs"): @@ -1082,6 +1074,28 @@ def load_extension(self, name): if hasattr(ins, methodname): self.text.bind(vevent, getattr(ins, methodname)) + def _load_extension_module(self, name): + fname = self.extfiles.get(name, name) + mod = None + first_exception = None + for modpath, package in [ + ['.' + fname, __package__], + [fname, None], + ['.' + fname.lower(), __package__], + [fname.lower(), None], + ]: + try: + mod = importlib.import_module(modpath, package=package) + except (ImportError, TypeError) as exc: + if first_exception is None: + first_exception = exc + continue + break + if mod is None: + print("\nFailed to import extension: ", name) + raise first_exception + return mod + def apply_bindings(self, keydefs=None): if keydefs is None: keydefs = self.mainmenu.default_keydefs diff --git a/Lib/idlelib/idle_test/test_config.py b/Lib/idlelib/idle_test/test_config.py index abfec7993e0744..111cff3b72e182 100644 --- a/Lib/idlelib/idle_test/test_config.py +++ b/Lib/idlelib/idle_test/test_config.py @@ -432,6 +432,8 @@ def test_default_keys(self): def test_get_extensions(self): userextn.read_string(''' + [Squeezer] + enable = True [ZzDummy] enable = True [DISABLE] @@ -439,11 +441,13 @@ def test_get_extensions(self): ''') eq = self.assertEqual iGE = idleConf.GetExtensions - eq(iGE(shell_only=True), []) - eq(iGE(), ['ZzDummy']) + eq(iGE(shell_only=True), ['Squeezer']) + eq(iGE(), ['Squeezer', 'ZzDummy']) eq(iGE(editor_only=True), ['ZzDummy']) - eq(iGE(active_only=False), ['ZzDummy', 'DISABLE']) - eq(iGE(active_only=False, editor_only=True), ['ZzDummy', 'DISABLE']) + eq(iGE(active_only=False), ['Squeezer', 'ZzDummy', 'DISABLE']) + eq(iGE(active_only=False, editor_only=True), + ['Squeezer', 'ZzDummy', 'DISABLE']) + userextn.remove_section('Squeezer') userextn.remove_section('ZzDummy') userextn.remove_section('DISABLE') @@ -453,7 +457,8 @@ def test_remove_key_bind_names(self): self.assertCountEqual( conf.RemoveKeyBindNames(conf.GetSectionList('default', 'extensions')), - ['AutoComplete', 'CodeContext', 'FormatParagraph', 'ParenMatch','ZzDummy']) + ['AutoComplete', 'CodeContext', 'FormatParagraph', 'ParenMatch', + 'Squeezer', 'ZzDummy']) def test_get_extn_name_for_event(self): userextn.read_string(''' diff --git a/Lib/idlelib/idle_test/test_squeezer.py b/Lib/idlelib/idle_test/test_squeezer.py new file mode 100644 index 00000000000000..88eab97214bd8f --- /dev/null +++ b/Lib/idlelib/idle_test/test_squeezer.py @@ -0,0 +1,725 @@ +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 captured_stderr, requires + +from idlelib.squeezer import count_lines_with_wrapping, ExpandingButton, \ + Squeezer +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.assertEqual( + expected, + count_lines_with_wrapping(text, linewidth, tabwidth), + ) + + def test_count_empty(self): + """test with an empty string""" + self.assertEqual(count_lines_with_wrapping(""), 0) + + def test_count_begins_with_empty_line(self): + """test with a string which begins with a newline""" + self.assertEqual(count_lines_with_wrapping("\ntext"), 2) + + def test_count_ends_with_empty_line(self): + """test with a string which ends with a newline""" + self.assertEqual(count_lines_with_wrapping("text\n"), 1) + + def test_count_several_lines(self): + """test with several lines of text""" + self.assertEqual(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.assertEqual(squeezer.expandingbuttons, []) + self.assertEqual(squeezer.text.bind.call_count, 1) + squeezer.text.bind.assert_called_with( + '<>', squeezer.squeeze_current_text_event) + self.assertEqual(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.assertEqual(squeezer.editwin.write(text, ()), SENTINEL_VALUE) + self.assertEqual(orig_write.call_count, 1) + orig_write.assert_called_with(text, ()) + self.assertEqual(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.assertEqual(squeezer.editwin.write(text, "stderr"), + SENTINEL_VALUE) + self.assertEqual(orig_write.call_count, 1) + orig_write.assert_called_with(text, "stderr") + self.assertEqual(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.assertEqual(squeezer.editwin.write(text, "stdout"), + SENTINEL_VALUE) + self.assertEqual(orig_write.call_count, 1) + orig_write.assert_called_with(text, "stdout") + self.assertEqual(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.assertEqual(squeezer.editwin.write(text, "stdout"), None) + self.assertEqual(orig_write.call_count, 0) + self.assertEqual(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.assertEqual(retval, "break") + self.assertEqual(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.assertEqual(retval, "break") + self.assertEqual(squeezer.text.bell.call_count, 0) + self.assertEqual(mock_expandingbutton1.expand.call_count, 0) + self.assertEqual(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.assertEqual(retval, "break") + self.assertEqual(squeezer.text.bell.call_count, 0) + self.assertEqual(mock_expandingbutton1.expand.call_count, 1) + self.assertEqual(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.assertEqual(retval, "break") + self.assertEqual(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.assertEqual(retval, "break") + self.assertEqual(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.assertEqual(retval, "break") + self.assertEqual(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.assertEqual(retval, "break") + self.assertEqual(squeezer.text.bell.call_count, 0) + self.assertEqual(mock_expandingbutton1.preview.call_count, 0) + self.assertEqual(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.assertEqual(text_widget.get('1.0', 'end'), '\n') + self.assertEqual(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.assertEqual(text_widget.get('1.0', 'end'), 'SOME\nTEXT\n\n') + + self.assertEqual(len(squeezer.expandingbuttons), 0) + + # test squeezing the current text + retval = squeezer.squeeze_current_text_event(event=Mock()) + self.assertEqual(retval, "break") + self.assertEqual(text_widget.get('1.0', 'end'), '\n\n') + self.assertEqual(len(squeezer.expandingbuttons), 1) + self.assertEqual(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.assertEqual(text_widget.get('1.0', 'end'), 'SOME\nTEXT\n\n') + self.assertEqual(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.assertEqual(text_widget.get('1.0', 'end'), 'SOME\nTEXT\n\n') + + self.assertEqual(len(squeezer.expandingbuttons), 0) + + # test squeezing the current text + retval = squeezer.squeeze_current_text_event(event=Mock()) + self.assertEqual(retval, "break") + self.assertEqual(text_widget.get('1.0', 'end'), 'SOME\nTEXT\n\n') + self.assertEqual(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.assertEqual(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.assertEqual(retval, "break") + self.assertEqual(text_widget.get('1.0', 'end'), '\n\n\n') + self.assertEqual(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.config.idleConf.GetOption') as MockGetOption: + MockGetOption.return_value = SENTINEL_VALUE + retval = Squeezer.get_auto_squeeze_min_lines() + + self.assertEqual(retval, SENTINEL_VALUE) + self.assertEqual(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.assertEqual(sig.type, "int") + self.assertNotEqual(sig.default, sentinel.NOT_GIVEN) + + def test_get_preview_command(self): + """test the preview-command config getter""" + fake_cmd = 'FAKE_VIEWER_APP {filepath}' + with patch('idlelib.config.idleConf.GetOption') as MockGetOption: + MockGetOption.return_value = fake_cmd + retval = Squeezer.get_preview_command() + + self.assertEqual(retval, fake_cmd) + self.assertEqual(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.assertNotEqual(sig.default, sentinel.NOT_GIVEN) + + def test_invalid_preview_command_template(self): + """test the preview-command config getter""" + with patch('idlelib.config.idleConf.GetOption') as MockGetOption: + MockGetOption.return_value = 'FAKE_VIEWER_APP {filepath' + with captured_stderr(): + retval = Squeezer.get_preview_command() + self.assertFalse(retval) + + def test_get_show_tooltip(self): + """test the show-tooltip config getter""" + with patch('idlelib.config.idleConf.GetOption') as MockGetOption: + MockGetOption.return_value = SENTINEL_VALUE + retval = Squeezer.get_show_tooltip() + + self.assertEqual(retval, SENTINEL_VALUE) + self.assertEqual(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.assertEqual(sig.type, "bool") + self.assertNotEqual(sig.default, sentinel.NOT_GIVEN) + + def test_get_tooltip_delay(self): + """test the tooltip-delay config getter""" + with patch('idlelib.config.idleConf.GetOption') as MockGetOption: + MockGetOption.return_value = SENTINEL_VALUE + retval = Squeezer.get_tooltip_delay() + + self.assertEqual(retval, SENTINEL_VALUE) + self.assertEqual(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.assertEqual(sig.type, "int") + self.assertNotEqual(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.config.idleConf.GetOption which + # is used by get_preview_command(). + with patch('idlelib.config.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.assertEqual(edit_menu[-1], preview_last_squeezed_menu_item) + self.assertEqual(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.assertEqual(expandingbutton.s, 'TEXT') + + # check that the underlying tkinter.Button is properly configured + self.assertEqual(expandingbutton.master, text_widget) + self.assertTrue('30 lines' in expandingbutton.cget('text')) + + # check that the text widget still contains no text + self.assertEqual(text_widget.get('1.0', 'end'), '\n') + + # check that no tooltip was created + self.assertEqual(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.assertEqual(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.assertEqual(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.assertEqual(retval, None) + + # check that the text was inserting into the text widget + self.assertEqual(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.assertEqual(text_widget.get('1.0', text_end_index), 'TEXT') + self.assertEqual(text_widget.tag_nextrange('TAGS', '1.0'), + ('1.0', text_end_index)) + + # check that the button removed itself from squeezer.expandingbuttons + self.assertEqual(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.assertEqual(retval, None) + + # check that the expanding button called clipboard_clear() and + # clipboard_append('TEXT') once each + self.assertEqual(expandingbutton.clipboard_clear.call_count, 1) + self.assertEqual(expandingbutton.clipboard_append.call_count, 1) + expandingbutton.clipboard_append.assert_called_with('TEXT') + + def _file_cleanup(self, filename): + if os.path.exists(filename): + try: + os.remove(filename) + except OSError: + pass + + def test_preview(self): + """test the preview event""" + squeezer = self.make_mock_squeezer() + squeezer.get_preview_command.return_value = 'FAKE_VIEWER_APP {filepath}' + expandingbutton = ExpandingButton('TEXT', 'TAGS', 30, squeezer) + expandingbutton.selection_own = Mock() + + with patch('subprocess.Popen') as mock_popen: + # trigger the preview event + expandingbutton.preview(event=Mock()) + + # check that the expanding button called Popen once + self.assertEqual(mock_popen.call_count, 1) + + command = mock_popen.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.assertEqual(viewer, 'FAKE_VIEWER_APP') + self.assertTrue(os.path.isfile(filename)) + + # cleanup - remove the temporary file after this test + self.addCleanup(self._file_cleanup, filename) + + # check that the temporary file contains the squeezed text + with open(filename, 'r') as f: + self.assertEqual(f.read(), 'TEXT') diff --git a/Lib/idlelib/squeezer.py b/Lib/idlelib/squeezer.py new file mode 100644 index 00000000000000..8d19714ddffe2b --- /dev/null +++ b/Lib/idlelib/squeezer.py @@ -0,0 +1,413 @@ +"""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 subprocess +import tempfile +import traceback +import sys + +import tkinter as tk +from tkinter.font import Font + +from idlelib.pyshell import PyShell +from idlelib.config 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) + filename = f.name + f.write(self.s) + f.close() + + cmdline = self.squeezer.get_preview_command().format(filepath=filename) + try: + if sys.platform[:3] == 'win': + p = subprocess.Popen(cmdline) + else: + p = subprocess.Popen(cmdline, close_fds=True, + start_new_session=True) + return p.poll() is None + except OSError: + traceback.print_exc() + + # Launching the preview failed, so remove the temporary file. + try: + os.remove(filename) + except OSError: + pass + + return False + + +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): + preview_cmd = idleConf.GetOption( + "extensions", "Squeezer", cls._PREVIEW_COMMAND_CONFIG_PARAM_NAME, + default="", raw=True, + ) + + if preview_cmd: + # check that the configured command template is valid + try: + preview_cmd.format(filepath='TESTING') + except Exception: + print(f"Invalid preview command template: {preview_cmd}", + file=sys.stderr) + traceback.print_exc() + return None + + return preview_cmd + + @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 --git a/Lib/idlelib/tooltip.py b/Lib/idlelib/tooltip.py index 843fb4a7d0b741..d58c6987a210f8 100644 --- a/Lib/idlelib/tooltip.py +++ b/Lib/idlelib/tooltip.py @@ -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 leave(self, event=None): 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 @@ def hidetip(self): 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") From 0af6a62245db4f830e89ab8fa12a74910cceae59 Mon Sep 17 00:00:00 2001 From: Tal Einat Date: Sun, 12 Aug 2018 12:08:56 +0300 Subject: [PATCH 02/16] bpo-1529353: use idlelib.textview for previewing squeezed output --- Lib/idlelib/config-extensions.def | 2 - Lib/idlelib/idle_test/test_squeezer.py | 159 ++----------------------- Lib/idlelib/squeezer.py | 93 +++------------ 3 files changed, 27 insertions(+), 227 deletions(-) diff --git a/Lib/idlelib/config-extensions.def b/Lib/idlelib/config-extensions.def index 2756dab2ba4556..2170af50433d3a 100644 --- a/Lib/idlelib/config-extensions.def +++ b/Lib/idlelib/config-extensions.def @@ -54,8 +54,6 @@ enable_editor= False auto-squeeze-min-lines= 30 show-tooltip= True tooltip-delay= 500 -preview-command-win= notepad.exe {filepath} -preview-command-posix= [Squeezer_bindings] expand-last-squeezed= preview-last-squeezed= diff --git a/Lib/idlelib/idle_test/test_squeezer.py b/Lib/idlelib/idle_test/test_squeezer.py index 88eab97214bd8f..b327117f7630d8 100644 --- a/Lib/idlelib/idle_test/test_squeezer.py +++ b/Lib/idlelib/idle_test/test_squeezer.py @@ -3,10 +3,11 @@ from tkinter import Text import unittest from unittest.mock import Mock, NonCallableMagicMock, patch, sentinel, ANY -from test.support import captured_stderr, requires +from test.support import requires from idlelib.squeezer import count_lines_with_wrapping, ExpandingButton, \ Squeezer +from idlelib.textview import view_text from idlelib.pyshell import PyShell @@ -266,22 +267,6 @@ def test_preview_last_squeezed_event_no_squeezed(self): self.assertEqual(retval, "break") self.assertEqual(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.assertEqual(retval, "break") - self.assertEqual(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 @@ -290,7 +275,6 @@ def test_preview_last_squeezed_event(self): # 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, @@ -441,32 +425,6 @@ def test_get_auto_squeeze_min_lines(self): self.assertEqual(sig.type, "int") self.assertNotEqual(sig.default, sentinel.NOT_GIVEN) - def test_get_preview_command(self): - """test the preview-command config getter""" - fake_cmd = 'FAKE_VIEWER_APP {filepath}' - with patch('idlelib.config.idleConf.GetOption') as MockGetOption: - MockGetOption.return_value = fake_cmd - retval = Squeezer.get_preview_command() - - self.assertEqual(retval, fake_cmd) - self.assertEqual(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.assertNotEqual(sig.default, sentinel.NOT_GIVEN) - - def test_invalid_preview_command_template(self): - """test the preview-command config getter""" - with patch('idlelib.config.idleConf.GetOption') as MockGetOption: - MockGetOption.return_value = 'FAKE_VIEWER_APP {filepath' - with captured_stderr(): - retval = Squeezer.get_preview_command() - self.assertFalse(retval) - def test_get_show_tooltip(self): """test the show-tooltip config getter""" with patch('idlelib.config.idleConf.GetOption') as MockGetOption: @@ -499,44 +457,6 @@ def test_get_tooltip_delay(self): self.assertEqual(sig.type, "int") self.assertNotEqual(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.config.idleConf.GetOption which - # is used by get_preview_command(). - with patch('idlelib.config.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.assertEqual(edit_menu[-1], preview_last_squeezed_menu_item) - self.assertEqual(len(edit_menu), edit_menu_len_without_preview_last + 1) - class TestExpandingButton(unittest.TestCase): """tests for the ExpandingButton class""" @@ -550,17 +470,15 @@ def make_mock_squeezer(self): # 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): + def test_init_no_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) @@ -576,36 +494,17 @@ def test_init_no_preview_command_nor_tooltip(self, MockToolTip): # check that no tooltip was created self.assertEqual(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 + # check that the mouse events are bound 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""" + def test_init_tooltip(self, MockToolTip): + """test tooltip creation""" 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 @@ -613,30 +512,10 @@ def test_init_tooltip_with_preview_command(self, MockToolTip): 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 + # check that 'right-click' appears in the tooltip text 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.assertEqual(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() @@ -698,28 +577,16 @@ def _file_cleanup(self, filename): def test_preview(self): """test the preview event""" squeezer = self.make_mock_squeezer() - squeezer.get_preview_command.return_value = 'FAKE_VIEWER_APP {filepath}' expandingbutton = ExpandingButton('TEXT', 'TAGS', 30, squeezer) expandingbutton.selection_own = Mock() - with patch('subprocess.Popen') as mock_popen: + with patch('idlelib.textview.view_text', autospec=view_text)\ + as mock_view_text: # trigger the preview event expandingbutton.preview(event=Mock()) - # check that the expanding button called Popen once - self.assertEqual(mock_popen.call_count, 1) - - command = mock_popen.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.assertEqual(viewer, 'FAKE_VIEWER_APP') - self.assertTrue(os.path.isfile(filename)) - - # cleanup - remove the temporary file after this test - self.addCleanup(self._file_cleanup, filename) + # check that the expanding button called view_text + self.assertEqual(mock_view_text.call_count, 1) - # check that the temporary file contains the squeezed text - with open(filename, 'r') as f: - self.assertEqual(f.read(), 'TEXT') + # check that the proper text was passed + self.assertEqual(mock_view_text.call_args[0][2], 'TEXT') diff --git a/Lib/idlelib/squeezer.py b/Lib/idlelib/squeezer.py index 8d19714ddffe2b..0bfca24d6dbe06 100644 --- a/Lib/idlelib/squeezer.py +++ b/Lib/idlelib/squeezer.py @@ -7,26 +7,21 @@ 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. +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 os import re -import subprocess -import tempfile -import traceback -import sys import tkinter as tk from tkinter.font import Font from idlelib.pyshell import PyShell from idlelib.config import idleConf +from idlelib.textview import view_text from idlelib.tooltip import ToolTip @@ -103,8 +98,8 @@ class ExpandingButton(tk.Button): 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. + the original text to the clipboard or preview 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). @@ -120,25 +115,21 @@ def __init__(self, s, tags, numoflines, squeezer): # 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 += "." + button_tooltip_text = ( + "Double-click to expand, middle-click to copy, " + + "right-click to preview." + ) 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.bind("", self.preview) self.selection_handle( lambda offset, length: s[int(offset):int(offset) + int(length)]) @@ -163,33 +154,9 @@ def copy(self, event): def preview(self, event): """preview event handler - View the original text in an external application, as configured in - the Squeezer instance. + View the original text in a separate text viewer window. """ - f = tempfile.NamedTemporaryFile(mode='w', suffix="longidletext", - delete=False) - filename = f.name - f.write(self.s) - f.close() - - cmdline = self.squeezer.get_preview_command().format(filepath=filename) - try: - if sys.platform[:3] == 'win': - p = subprocess.Popen(cmdline) - else: - p = subprocess.Popen(cmdline, close_fds=True, - start_new_session=True) - return p.poll() is None - except OSError: - traceback.print_exc() - - # Launching the preview failed, so remove the temporary file. - try: - os.remove(filename) - except OSError: - pass - - return False + view_text(self.text, "Squeezed Output Viewer", self.s) class Squeezer: @@ -201,29 +168,6 @@ def get_auto_squeeze_min_lines(cls): 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): - preview_cmd = idleConf.GetOption( - "extensions", "Squeezer", cls._PREVIEW_COMMAND_CONFIG_PARAM_NAME, - default="", raw=True, - ) - - if preview_cmd: - # check that the configured command template is valid - try: - preview_cmd.format(filepath='TESTING') - except Exception: - print(f"Invalid preview command template: {preview_cmd}", - file=sys.stderr) - traceback.print_exc() - return None - - return preview_cmd - @classmethod def get_show_tooltip(cls): return idleConf.GetOption( @@ -242,10 +186,7 @@ def get_tooltip_delay(cls): ('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", "<>") + ("Preview last squeezed text", "<>"), ]), ] @@ -351,7 +292,7 @@ def preview_last_squeezed_event(self, event): 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: + if len(self.expandingbuttons) > 0: self.expandingbuttons[-1].preview(event) else: self.text.bell() @@ -405,9 +346,3 @@ def squeeze_current_text_event(self, event): 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", - "<>")) From bda6572f6cffa4126c2fb8f2f2f8fad699198df7 Mon Sep 17 00:00:00 2001 From: Tal Einat Date: Mon, 13 Aug 2018 16:32:50 +0300 Subject: [PATCH 03/16] Refactor Squeezer from an extension to an integral part of IDLE. --- Lib/idlelib/config-extensions.def | 10 --- Lib/idlelib/config-main.def | 5 ++ Lib/idlelib/configdialog.py | 78 ++++++++++++++++++- Lib/idlelib/editor.py | 39 ++++------ Lib/idlelib/idle_test/htest.py | 2 +- Lib/idlelib/idle_test/test_config.py | 19 ++--- Lib/idlelib/idle_test/test_squeezer.py | 61 ++------------- Lib/idlelib/pyshell.py | 16 ++++ Lib/idlelib/squeezer.py | 71 +++++++++-------- ...2018-08-13-16-31-24.bpo-1529353.wXfQJk.rst | 3 + 10 files changed, 163 insertions(+), 141 deletions(-) create mode 100644 Misc/NEWS.d/next/IDLE/2018-08-13-16-31-24.bpo-1529353.wXfQJk.rst diff --git a/Lib/idlelib/config-extensions.def b/Lib/idlelib/config-extensions.def index 2170af50433d3a..7e23fb0a73d1d5 100644 --- a/Lib/idlelib/config-extensions.def +++ b/Lib/idlelib/config-extensions.def @@ -48,16 +48,6 @@ bell= True # # See config-keys.def for notes on specifying keys and extend.txt for # information on creating IDLE extensions. -[Squeezer] -enable= True -enable_editor= False -auto-squeeze-min-lines= 30 -show-tooltip= True -tooltip-delay= 500 -[Squeezer_bindings] -expand-last-squeezed= -preview-last-squeezed= -squeeze-current-text= # A fake extension for testing and example purposes. When enabled and # invoked, inserts or deletes z-text at beginning of every line. diff --git a/Lib/idlelib/config-main.def b/Lib/idlelib/config-main.def index 16f4b0959cf13c..8142eff89d99f8 100644 --- a/Lib/idlelib/config-main.def +++ b/Lib/idlelib/config-main.def @@ -66,6 +66,11 @@ font-size= 10 font-bold= 0 encoding= none +[PyShell] +auto-squeeze-min-lines= 30 +show-squeezed-tooltips= 1 +squeezed-tooltips-delay= 0 + [Indent] use-spaces= 1 num-spaces= 4 diff --git a/Lib/idlelib/configdialog.py b/Lib/idlelib/configdialog.py index e682ec0da3200e..be6d73463426aa 100644 --- a/Lib/idlelib/configdialog.py +++ b/Lib/idlelib/configdialog.py @@ -30,10 +30,12 @@ from idlelib.codecontext import CodeContext from idlelib.parenmatch import ParenMatch from idlelib.paragraph import FormatParagraph +from idlelib.squeezer import Squeezer changes = ConfigChanges() # Reload changed options in the following classes. -reloadables = (AutoComplete, CodeContext, ParenMatch, FormatParagraph) +reloadables = (AutoComplete, CodeContext, ParenMatch, FormatParagraph, + Squeezer) class ConfigDialog(Toplevel): @@ -1748,9 +1750,9 @@ def delete_custom_keys(self): self.customlist.SetMenu(item_list, item_list[0]) # Revert to default key set. self.keyset_source.set(idleConf.defaultCfg['main'] - .Get('Keys', 'default')) + .Get('Keys', 'default')) self.builtin_name.set(idleConf.defaultCfg['main'].Get('Keys', 'name') - or idleConf.default_keys()) + or idleConf.default_keys()) # User can't back out of these changes, they must be applied now. changes.save_all() self.cd.save_all_changed_extensions() @@ -1817,6 +1819,16 @@ def create_page_general(self): frame_context: Frame context_title: Label (*)context_int: Entry - context_lines + frame_shell: LabelFrame + frame_auto_squeeze_min_lines: Frame + auto_squeeze_min_lines_title: Label + (*)auto_squeeze_min_lines_int: Entry - auto_squeeze_min_lines + frame_show_squeezed_tooltips: Frame + show_squeezed_tooltips_title: Label + (*)show_squeezed_tooltips_bool: Entry - show_squeezed_tooltips + frame_squeezed_tooltips_delay: Frame + squeezed_tooltips_delay_title: Label + (*)squeezed_tooltips_delay_int: Entry - squeezed_tooltips_delay frame_help: LabelFrame frame_helplist: Frame frame_helplist_buttons: Frame @@ -1842,6 +1854,13 @@ def create_page_general(self): self.paren_bell = tracers.add( BooleanVar(self), ('extensions', 'ParenMatch', 'bell')) + self.auto_squeeze_min_lines = tracers.add( + StringVar(self), ('main', 'PyShell', 'auto-squeeze-min-lines')) + self.show_squeezed_tooltips = tracers.add( + StringVar(self), ('main', 'PyShell', 'show-squeezed-tooltips')) + self.squeezed_tooltips_delay = tracers.add( + StringVar(self), ('main', 'PyShell', 'squeezed-tooltips-delay')) + self.autosave = tracers.add( IntVar(self), ('main', 'General', 'autosave')) self.format_width = tracers.add( @@ -1855,8 +1874,10 @@ def create_page_general(self): text=' Window Preferences') frame_editor = LabelFrame(self, borderwidth=2, relief=GROOVE, text=' Editor Preferences') + frame_shell = LabelFrame(self, borderwidth=2, relief=GROOVE, + text=' Shell Preferences') frame_help = LabelFrame(self, borderwidth=2, relief=GROOVE, - text=' Additional Help Sources ') + text=' Additional Help Sources ') # Frame_window. frame_run = Frame(frame_window, borderwidth=0) startup_title = Label(frame_run, text='At Startup') @@ -1918,6 +1939,25 @@ def create_page_general(self): self.context_int = Entry( frame_context, textvariable=self.context_lines, width=3) + # Frame_shell. + frame_auto_squeeze_min_lines = Frame(frame_shell, borderwidth=0) + auto_squeeze_min_lines_title = Label(frame_auto_squeeze_min_lines, + text='Auto-Squeeze Min. Lines:') + self.auto_squeeze_min_lines_int = Entry( + frame_auto_squeeze_min_lines, width=4, + textvariable=self.auto_squeeze_min_lines) + frame_show_squeezed_tooltips = Frame(frame_shell, borderwidth=0) + show_squeezed_tooltips_title = Label(frame_show_squeezed_tooltips, + text='Show Squeezed Tooltips:') + self.show_squeezed_tooltips_on = Checkbutton( + frame_show_squeezed_tooltips, + variable=self.show_squeezed_tooltips) + frame_squeezed_tooltips_delay = Frame(frame_shell, borderwidth=0) + squeezed_tooltips_delay_title = Label(frame_squeezed_tooltips_delay, + text='Squeezed Tooltip Delay:') + self.squeezed_tooltips_delay_int = Entry( + frame_squeezed_tooltips_delay, width=4, + textvariable=self.squeezed_tooltips_delay) # frame_help. frame_helplist = Frame(frame_help) @@ -1943,6 +1983,7 @@ def create_page_general(self): # Body. frame_window.pack(side=TOP, padx=5, pady=5, expand=TRUE, fill=BOTH) frame_editor.pack(side=TOP, padx=5, pady=5, expand=TRUE, fill=BOTH) + frame_shell.pack(side=TOP, padx=5, pady=5, expand=TRUE, fill=BOTH) frame_help.pack(side=TOP, padx=5, pady=5, expand=TRUE, fill=BOTH) # frame_run. frame_run.pack(side=TOP, padx=5, pady=0, fill=X) @@ -1983,6 +2024,19 @@ def create_page_general(self): context_title.pack(side=LEFT, anchor=W, padx=5, pady=5) self.context_int.pack(side=TOP, padx=5, pady=5) + # frame_auto_squeeze_min_lines + frame_auto_squeeze_min_lines.pack(side=TOP, padx=5, pady=0, fill=X) + auto_squeeze_min_lines_title.pack(side=LEFT, anchor=W, padx=5, pady=5) + self.auto_squeeze_min_lines_int.pack(side=TOP, padx=5, pady=5) + + frame_show_squeezed_tooltips.pack(side=TOP, padx=5, pady=0, fill=X) + show_squeezed_tooltips_title.pack(side=LEFT, anchor=W, padx=5, pady=5) + self.show_squeezed_tooltips_on.pack(side=TOP, padx=5, pady=5) + + frame_squeezed_tooltips_delay.pack(side=TOP, padx=5, pady=0, fill=X) + squeezed_tooltips_delay_title.pack(side=LEFT, anchor=W, padx=5, pady=5) + self.squeezed_tooltips_delay_int.pack(side=TOP, padx=5, pady=5) + # frame_help. frame_helplist_buttons.pack(side=RIGHT, padx=5, pady=5, fill=Y) frame_helplist.pack(side=TOP, padx=5, pady=5, expand=TRUE, fill=BOTH) @@ -2018,6 +2072,14 @@ def load_general_cfg(self): self.context_lines.set(idleConf.GetOption( 'extensions', 'CodeContext', 'maxlines', type='int')) + # Set variables for shell windows. + self.auto_squeeze_min_lines.set(idleConf.GetOption( + 'main', 'PyShell', 'auto-squeeze-min-lines', type='int')) + self.show_squeezed_tooltips.set(idleConf.GetOption( + 'main', 'PyShell', 'show-squeezed-tooltips', type='bool')) + self.squeezed_tooltips_delay.set(idleConf.GetOption( + 'main', 'PyShell', 'squeezed-tooltips-delay', type='int')) + # Set additional help sources. self.user_helplist = idleConf.GetAllExtraHelpSourcesList() self.helplist.delete(0, 'end') @@ -2211,6 +2273,14 @@ def detach(self): CodeContext: Maxlines is the maximum number of code context lines to display when Code Context is turned on for an editor window. + +Shell Preferences: +* Auto-Squeeze Min. Lines is the minimum number of lines of output to + automatically "squeeze". +* Show Squeezed Tooltips toggles whether tooltips are shown for squeezed + outputs. +* Squeezed Tooltips Delay sets the mouse hover delay over a squeezed output + before its tooltip is shown, in milliseconds. ''' } diff --git a/Lib/idlelib/editor.py b/Lib/idlelib/editor.py index 165ba5be029dff..6689af64c429be 100644 --- a/Lib/idlelib/editor.py +++ b/Lib/idlelib/editor.py @@ -2,9 +2,7 @@ import importlib.util import os import platform -import re import string -import sys import tokenize import traceback import webbrowser @@ -50,7 +48,6 @@ class EditorWindow(object): from idlelib.undo import UndoDelegator from idlelib.iomenu import IOBinding, encoding from idlelib import mainmenu - from tkinter import Toplevel, EventType from idlelib.statusbar import MultiStatusBar from idlelib.autocomplete import AutoComplete from idlelib.autoexpand import AutoExpand @@ -59,6 +56,7 @@ class EditorWindow(object): from idlelib.paragraph import FormatParagraph from idlelib.parenmatch import ParenMatch from idlelib.rstrip import Rstrip + from idlelib.squeezer import Squeezer from idlelib.zoomheight import ZoomHeight filesystemencoding = sys.getfilesystemencoding() # for file names @@ -319,6 +317,9 @@ def __init__(self, flist=None, filename=None, key=None, root=None): text.bind("<>", self.ZoomHeight(self).zoom_height_event) text.bind("<>", self.CodeContext(self).toggle_code_context_event) + squeezer = self.Squeezer(self) + text.bind("<>", + squeezer.squeeze_current_text_event) def _filename_to_unicode(self, filename): """Return filename as BMP unicode so diplayable in Tk.""" @@ -1062,7 +1063,15 @@ def get_standard_extension_names(self): } def load_extension(self, name): - mod = self._load_extension_module(name) + fname = self.extfiles.get(name, name) + try: + try: + mod = importlib.import_module('.' + fname, package=__package__) + except (ImportError, TypeError): + mod = importlib.import_module(fname) + except ImportError: + print("\nFailed to import extension: ", name) + raise cls = getattr(mod, name) keydefs = idleConf.GetExtensionBindings(name) if hasattr(cls, "menudefs"): @@ -1081,28 +1090,6 @@ def load_extension(self, name): if hasattr(ins, methodname): self.text.bind(vevent, getattr(ins, methodname)) - def _load_extension_module(self, name): - fname = self.extfiles.get(name, name) - mod = None - first_exception = None - for modpath, package in [ - ['.' + fname, __package__], - [fname, None], - ['.' + fname.lower(), __package__], - [fname.lower(), None], - ]: - try: - mod = importlib.import_module(modpath, package=package) - except (ImportError, TypeError) as exc: - if first_exception is None: - first_exception = exc - continue - break - if mod is None: - print("\nFailed to import extension: ", name) - raise first_exception - return mod - def apply_bindings(self, keydefs=None): if keydefs is None: keydefs = self.mainmenu.default_keydefs diff --git a/Lib/idlelib/idle_test/htest.py b/Lib/idlelib/idle_test/htest.py index 03bee517073575..8c1c24d070cc8f 100644 --- a/Lib/idlelib/idle_test/htest.py +++ b/Lib/idlelib/idle_test/htest.py @@ -163,7 +163,7 @@ def _wrapper(parent): # htest # 'msg': "Click the 'Show GrepDialog' button.\n" "Test the various 'Find-in-files' functions.\n" "The results should be displayed in a new '*Output*' window.\n" - "'Right-click'->'Goto file/line' anywhere in the search results " + "'Right-click'->'Go to file/line' anywhere in the search results " "should open that file \nin a new EditorWindow." } diff --git a/Lib/idlelib/idle_test/test_config.py b/Lib/idlelib/idle_test/test_config.py index befa629b3519d9..8c9197284e07e4 100644 --- a/Lib/idlelib/idle_test/test_config.py +++ b/Lib/idlelib/idle_test/test_config.py @@ -356,11 +356,11 @@ def test_get_section_list(self): self.assertCountEqual( conf.GetSectionList('default', 'main'), - ['General', 'EditorWindow', 'Indent', 'Theme', + ['General', 'EditorWindow', 'PyShell', 'Indent', 'Theme', 'Keys', 'History', 'HelpFiles']) self.assertCountEqual( conf.GetSectionList('user', 'main'), - ['General', 'EditorWindow', 'Indent', 'Theme', + ['General', 'EditorWindow', 'PyShell', 'Indent', 'Theme', 'Keys', 'History', 'HelpFiles']) with self.assertRaises(config.InvalidConfigSet): @@ -431,8 +431,6 @@ def test_default_keys(self): def test_get_extensions(self): userextn.read_string(''' - [Squeezer] - enable = True [ZzDummy] enable = True [DISABLE] @@ -440,13 +438,11 @@ def test_get_extensions(self): ''') eq = self.assertEqual iGE = idleConf.GetExtensions - eq(iGE(shell_only=True), ['Squeezer']) - eq(iGE(), ['Squeezer', 'ZzDummy']) + eq(iGE(shell_only=True), []) + eq(iGE(), ['ZzDummy']) eq(iGE(editor_only=True), ['ZzDummy']) - eq(iGE(active_only=False), ['Squeezer', 'ZzDummy', 'DISABLE']) - eq(iGE(active_only=False, editor_only=True), - ['Squeezer', 'ZzDummy', 'DISABLE']) - userextn.remove_section('Squeezer') + eq(iGE(active_only=False), ['ZzDummy', 'DISABLE']) + eq(iGE(active_only=False, editor_only=True), ['ZzDummy', 'DISABLE']) userextn.remove_section('ZzDummy') userextn.remove_section('DISABLE') @@ -456,8 +452,7 @@ def test_remove_key_bind_names(self): self.assertCountEqual( conf.RemoveKeyBindNames(conf.GetSectionList('default', 'extensions')), - ['AutoComplete', 'CodeContext', 'FormatParagraph', 'ParenMatch', - 'Squeezer', 'ZzDummy']) + ['AutoComplete', 'CodeContext', 'FormatParagraph', 'ParenMatch', 'ZzDummy']) def test_get_extn_name_for_event(self): userextn.read_string(''' diff --git a/Lib/idlelib/idle_test/test_squeezer.py b/Lib/idlelib/idle_test/test_squeezer.py index df988e8a480f37..8221dedbecc397 100644 --- a/Lib/idlelib/idle_test/test_squeezer.py +++ b/Lib/idlelib/idle_test/test_squeezer.py @@ -5,6 +5,7 @@ from unittest.mock import Mock, NonCallableMagicMock, patch, sentinel, ANY from test.support import requires +from idlelib.config import idleConf from idlelib.squeezer import count_lines_with_wrapping, ExpandingButton, \ Squeezer from idlelib.textview import view_text @@ -136,16 +137,9 @@ def _test_count_lines_helper(self, 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.assertEqual(squeezer.expandingbuttons, []) - self.assertEqual(squeezer.text.bind.call_count, 1) - squeezer.text.bind.assert_called_with( - '<>', squeezer.squeeze_current_text_event) - self.assertEqual(editwin.rmenu_specs, [ - ("Squeeze current text", "<>"), - ]) def test_write_no_tags(self): """test Squeezer's overriding of the EditorWindow's write() method""" @@ -410,53 +404,12 @@ 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.config.idleConf.GetOption') as MockGetOption: - MockGetOption.return_value = SENTINEL_VALUE - retval = Squeezer.get_auto_squeeze_min_lines() - - self.assertEqual(retval, SENTINEL_VALUE) - self.assertEqual(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.assertEqual(sig.type, "int") - self.assertNotEqual(sig.default, sentinel.NOT_GIVEN) - - def test_get_show_tooltip(self): - """test the show-tooltip config getter""" - with patch('idlelib.config.idleConf.GetOption') as MockGetOption: - MockGetOption.return_value = SENTINEL_VALUE - retval = Squeezer.get_show_tooltip() - - self.assertEqual(retval, SENTINEL_VALUE) - self.assertEqual(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.assertEqual(sig.type, "bool") - self.assertNotEqual(sig.default, sentinel.NOT_GIVEN) - - def test_get_tooltip_delay(self): - """test the tooltip-delay config getter""" - with patch('idlelib.config.idleConf.GetOption') as MockGetOption: - MockGetOption.return_value = SENTINEL_VALUE - retval = Squeezer.get_tooltip_delay() - - self.assertEqual(retval, SENTINEL_VALUE) - self.assertEqual(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.assertEqual(sig.type, "int") - self.assertNotEqual(sig.default, sentinel.NOT_GIVEN) + def test_reload(self): + """test the reload() class-method""" + self.assertIsInstance(Squeezer.auto_squeeze_min_lines, int) + idleConf.SetOption('main', 'PyShell', 'auto-squeeze-min-lines', '42') + Squeezer.reload() + self.assertEqual(Squeezer.auto_squeeze_min_lines, 42) class TestExpandingButton(unittest.TestCase): diff --git a/Lib/idlelib/pyshell.py b/Lib/idlelib/pyshell.py index 52c11e30dbd500..dcd822d0ff9b39 100755 --- a/Lib/idlelib/pyshell.py +++ b/Lib/idlelib/pyshell.py @@ -856,6 +856,20 @@ class PyShell(OutputWindow): ("help", "_Help"), ] + # Extend menus + menu_extras = [ + ('edit', [ + None, # Separator + ("Expand last squeezed text", "<>"), + ("Preview last squeezed text", "<>"), + ]), + ] + + # Extend right-click context menu + rmenu_specs = OutputWindow.rmenu_specs + [ + ("Squeeze", "<>"), + ] + # New classes from idlelib.history import History @@ -874,6 +888,8 @@ def __init__(self, flist=None): OutputWindow.__init__(self, flist, None, None) + self.fill_menus(self.menu_extras) + self.usetabs = True # indentwidth must be 8 when using tabs. See note in EditorWindow: self.indentwidth = 8 diff --git a/Lib/idlelib/squeezer.py b/Lib/idlelib/squeezer.py index 0b5050babdde84..8487a8093b5939 100644 --- a/Lib/idlelib/squeezer.py +++ b/Lib/idlelib/squeezer.py @@ -19,7 +19,6 @@ import tkinter as tk from tkinter.font import Font -from idlelib.pyshell import PyShell from idlelib.config import idleConf from idlelib.textview import view_text from idlelib.tooltip import Hovertip @@ -119,13 +118,13 @@ def __init__(self, s, tags, numoflines, squeezer): tk.Button.__init__(self, text, text=button_text, background="#FFFFC0", activebackground="#FFFFE0") - if self.squeezer.get_show_tooltip(): + if self.squeezer.should_show_tooltip: button_tooltip_text = ( "Double-click to expand, middle-click to copy, " + "right-click to preview." ) Hovertip(self, button_tooltip_text, - hover_delay=self.squeezer.get_tooltip_delay()) + hover_delay=self.squeezer.tooltip_delay) self.bind("", self.expand) self.bind("", self.copy) @@ -160,48 +159,48 @@ def preview(self, event): class Squeezer: - """An IDLE extension for "squeezing" long texts into a simple button.""" + """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. + """ @classmethod - def get_auto_squeeze_min_lines(cls): - return idleConf.GetOption( - "extensions", "Squeezer", "auto-squeeze-min-lines", + def reload(cls): + """Load class variables from config.""" + cls.auto_squeeze_min_lines = idleConf.GetOption( + "main", "PyShell", "auto-squeeze-min-lines", type="int", default=30, ) - - @classmethod - def get_show_tooltip(cls): - return idleConf.GetOption( - "extensions", "Squeezer", "show-tooltip", + cls.should_show_tooltip = idleConf.GetOption( + "main", "PyShell", "show-squeezed-tooltips", type="bool", default=True, ) - - @classmethod - def get_tooltip_delay(cls): - return idleConf.GetOption( - "extensions", "Squeezer", "tooltip-delay", + cls.tooltip_delay = idleConf.GetOption( + "main", "PyShell", "squeezed-tooltips-delay", type="int", default=0, ) - menudefs = [ - ('edit', [ - None, # Separator - ("Expand last squeezed text", "<>"), - ("Preview last squeezed text", "<>"), - ]), - ] - 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. But Squeezer deliberately needs to make such - # changes. + # the actual Text widget. Squeezer, however, needs to make such changes. self.base_text = editwin.per.bottom self.expandingbuttons = [] + from idlelib.pyshell import PyShell # done here to avoid import cycle 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. @@ -213,7 +212,7 @@ def mywrite(s, tags=(), write=editwin.write): # 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(): + if numoflines < self.auto_squeeze_min_lines: return write(s, tags) # create an ExpandingButton instance @@ -233,12 +232,6 @@ def mywrite(s, tags=(), write=editwin.write): 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. @@ -346,3 +339,13 @@ def squeeze_current_text_event(self, event): 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. diff --git a/Misc/NEWS.d/next/IDLE/2018-08-13-16-31-24.bpo-1529353.wXfQJk.rst b/Misc/NEWS.d/next/IDLE/2018-08-13-16-31-24.bpo-1529353.wXfQJk.rst new file mode 100644 index 00000000000000..cae4af8f2e2398 --- /dev/null +++ b/Misc/NEWS.d/next/IDLE/2018-08-13-16-31-24.bpo-1529353.wXfQJk.rst @@ -0,0 +1,3 @@ +Enable "squeezing" of long outputs in the shell, to avoid performance +degradation and to clean up the history without losing it. Squeezed outputs +may be copied, viewed in a separate window, and "unsqueezed". From 902091e9d8e1600476469307fdc6d47dc2f943ad Mon Sep 17 00:00:00 2001 From: Tal Einat Date: Tue, 28 Aug 2018 00:08:45 +0300 Subject: [PATCH 04/16] Update Squeezer's tests given the latest changes --- Lib/idlelib/idle_test/test_squeezer.py | 28 ++++++++++++++------------ 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/Lib/idlelib/idle_test/test_squeezer.py b/Lib/idlelib/idle_test/test_squeezer.py index 8221dedbecc397..56dbf3ac4e9bfc 100644 --- a/Lib/idlelib/idle_test/test_squeezer.py +++ b/Lib/idlelib/idle_test/test_squeezer.py @@ -176,10 +176,10 @@ def test_write_stdout(self): 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) + squeezer.auto_squeeze_min_lines = 30 self.assertEqual(squeezer.editwin.write(text, "stdout"), - SENTINEL_VALUE) + SENTINEL_VALUE) self.assertEqual(orig_write.call_count, 1) orig_write.assert_called_with(text, "stdout") self.assertEqual(len(squeezer.expandingbuttons), 0) @@ -187,7 +187,7 @@ def test_write_stdout(self): 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) + squeezer.auto_squeeze_min_lines = 30 self.assertEqual(squeezer.editwin.write(text, "stdout"), None) self.assertEqual(orig_write.call_count, 0) @@ -256,11 +256,9 @@ def test_preview_last_squeezed_event_no_squeezed(self): # 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.assertEqual(retval, "break") - self.assertEqual(squeezer.text.bell.call_count, 1) def test_preview_last_squeezed_event(self): """test the preview_last_squeezed event""" @@ -283,6 +281,10 @@ def test_preview_last_squeezed_event(self): self.assertEqual(mock_expandingbutton2.preview.call_count, 1) mock_expandingbutton2.preview.assert_called_with(SENTINEL_VALUE) + squeezer.preview_last_squeezed_event(event=SENTINEL_VALUE) + self.assertEqual(mock_expandingbutton1.preview.call_count, 0) + self.assertEqual(mock_expandingbutton2.preview.call_count, 2) + def test_auto_squeeze(self): """test that the auto-squeezing creates an ExpandingButton properly""" requires('gui') @@ -292,7 +294,7 @@ def test_auto_squeeze(self): 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.auto_squeeze_min_lines = 5 squeezer.count_lines = Mock(return_value=6) editwin.write('TEXT\n'*6, "stdout") @@ -423,16 +425,16 @@ def make_mock_squeezer(self): squeezer.editwin.text = Text() # Set default values for the configuration settings - squeezer.get_max_num_of_lines = Mock(return_value=30) - squeezer.get_show_tooltip = Mock(return_value=False) - squeezer.get_tooltip_delay = Mock(return_value=1500) + squeezer.auto_squeeze_min_lines = 30 + squeezer.should_show_tooltip = False + squeezer.tooltip_delay = 1500 return squeezer @patch('idlelib.squeezer.Hovertip', autospec=Hovertip) def test_init_no_tooltip(self, MockHovertip): """Test the simplest creation of an ExpandingButton""" squeezer = self.make_mock_squeezer() - squeezer.get_show_tooltip.return_value = False + squeezer.should_show_tooltip = False text_widget = squeezer.editwin.text expandingbutton = ExpandingButton('TEXT', 'TAGS', 30, squeezer) @@ -457,8 +459,8 @@ def test_init_no_tooltip(self, MockHovertip): def test_init_tooltip(self, MockHovertip): """test tooltip creation""" squeezer = self.make_mock_squeezer() - squeezer.get_show_tooltip.return_value = True - squeezer.get_tooltip_delay.return_value = SENTINEL_VALUE + squeezer.should_show_tooltip = True + squeezer.tooltip_delay = SENTINEL_VALUE expandingbutton = ExpandingButton('TEXT', 'TAGS', 30, squeezer) # check that ToolTip was called once, with appropriate values @@ -534,7 +536,7 @@ def test_preview(self): expandingbutton = ExpandingButton('TEXT', 'TAGS', 30, squeezer) expandingbutton.selection_own = Mock() - with patch('idlelib.textview.view_text', autospec=view_text)\ + with patch('idlelib.squeezer.view_text', autospec=view_text)\ as mock_view_text: # trigger the preview event expandingbutton.preview(event=Mock()) From 6d7035bb48b2bc0abf49577c6b00c60b3b420831 Mon Sep 17 00:00:00 2001 From: Tal Einat Date: Tue, 28 Aug 2018 14:50:40 +0300 Subject: [PATCH 05/16] add ability to control text wrapping in IDLE's text viewer --- Lib/idlelib/idle_test/test_textview.py | 7 +++++-- Lib/idlelib/squeezer.py | 2 +- Lib/idlelib/textview.py | 24 ++++++++++++++---------- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/Lib/idlelib/idle_test/test_textview.py b/Lib/idlelib/idle_test/test_textview.py index 0d11e41e0fb450..6f0c1930518a51 100644 --- a/Lib/idlelib/idle_test/test_textview.py +++ b/Lib/idlelib/idle_test/test_textview.py @@ -73,7 +73,6 @@ class TextFrameTest(unittest.TestCase): @classmethod def setUpClass(cls): - "By itself, this tests that file parsed without exception." cls.root = root = Tk() root.withdraw() cls.frame = tv.TextFrame(root, 'test text') @@ -126,11 +125,15 @@ def test_bad_file(self): def test_bad_encoding(self): p = os.path fn = p.abspath(p.join(p.dirname(__file__), '..', 'CREDITS.txt')) - tv.showerror.title = None view = tv.view_file(root, 'Title', fn, 'ascii', modal=False) self.assertIsNone(view) self.assertEqual(tv.showerror.title, 'Unicode Decode Error') + def test_nowrap(self): + view = tv.view_text(root, 'Title', 'test', modal=False, wrap='none') + text_widget = view.viewframe.textframe.text + self.assertEqual(text_widget.cget('wrap'), 'none') + # Call ViewWindow with _utest=True. class ButtonClickTest(unittest.TestCase): diff --git a/Lib/idlelib/squeezer.py b/Lib/idlelib/squeezer.py index 8487a8093b5939..a0099fecd7a616 100644 --- a/Lib/idlelib/squeezer.py +++ b/Lib/idlelib/squeezer.py @@ -155,7 +155,7 @@ def preview(self, event): View the original text in a separate text viewer window. """ - view_text(self.text, "Squeezed Output Viewer", self.s) + view_text(self.text, "Squeezed Output Viewer", self.s, wrap='none') class Squeezer: diff --git a/Lib/idlelib/textview.py b/Lib/idlelib/textview.py index 75b24703b4c357..9e862eea6fb4cc 100644 --- a/Lib/idlelib/textview.py +++ b/Lib/idlelib/textview.py @@ -9,7 +9,7 @@ class TextFrame(Frame): "Display text with scrollbar." - def __init__(self, parent, rawtext): + def __init__(self, parent, rawtext, wrap='word'): """Create a frame for Textview. parent - parent widget for this frame @@ -22,7 +22,7 @@ def __init__(self, parent, rawtext): self.bg = '#ffffff' self.fg = '#000000' - self.text = text = Text(self, wrap='word', highlightthickness=0, + self.text = text = Text(self, wrap=wrap, highlightthickness=0, fg=self.fg, bg=self.bg) self.scroll = scroll = Scrollbar(self, orient='vertical', takefocus=False, command=text.yview) @@ -37,12 +37,12 @@ def __init__(self, parent, rawtext): class ViewFrame(Frame): "Display TextFrame and Close button." - def __init__(self, parent, text): + def __init__(self, parent, text, wrap='word'): super().__init__(parent) self.parent = parent self.bind('', self.ok) self.bind('', self.ok) - self.textframe = TextFrame(self, text) + self.textframe = TextFrame(self, text, wrap=wrap) self.button_ok = button_ok = Button( self, text='Close', command=self.ok, takefocus=False) self.textframe.pack(side='top', expand=True, fill='both') @@ -56,7 +56,7 @@ def ok(self, event=None): class ViewWindow(Toplevel): "A simple text viewer dialog for IDLE." - def __init__(self, parent, title, text, modal=True, + def __init__(self, parent, title, text, modal=True, wrap='word', *, _htest=False, _utest=False): """Show the given text in a scrollable window with a 'close' button. @@ -66,6 +66,7 @@ def __init__(self, parent, title, text, modal=True, parent - parent of this dialog title - string which is title of popup dialog text - text to display in dialog + wrap - type of text wrapping to use ('word', 'char' or 'none') _htest - bool; change box location when running htest. _utest - bool; don't wait_window when running unittest. """ @@ -77,7 +78,7 @@ def __init__(self, parent, title, text, modal=True, self.geometry(f'=750x500+{x}+{y}') self.title(title) - self.viewframe = ViewFrame(self, text) + self.viewframe = ViewFrame(self, text, wrap=wrap) self.protocol("WM_DELETE_WINDOW", self.ok) self.button_ok = button_ok = Button(self, text='Close', command=self.ok, takefocus=False) @@ -97,20 +98,22 @@ def ok(self, event=None): self.destroy() -def view_text(parent, title, text, modal=True, _utest=False): +def view_text(parent, title, text, modal=True, wrap='word', _utest=False): """Create text viewer for given text. parent - parent of this dialog title - string which is the title of popup dialog text - text to display in this dialog + wrap - type of text wrapping to use ('word', 'char' or 'none') modal - controls if users can interact with other windows while this dialog is displayed _utest - bool; controls wait_window on unittest """ - return ViewWindow(parent, title, text, modal, _utest=_utest) + return ViewWindow(parent, title, text, modal, wrap=wrap, _utest=_utest) -def view_file(parent, title, filename, encoding, modal=True, _utest=False): +def view_file(parent, title, filename, encoding, modal=True, wrap='word', + _utest=False): """Create text viewer for text in filename. Return error message if file cannot be read. Otherwise calls view_text @@ -128,7 +131,8 @@ def view_file(parent, title, filename, encoding, modal=True, _utest=False): message=str(err), parent=parent) else: - return view_text(parent, title, contents, modal, _utest=_utest) + return view_text(parent, title, contents, modal, wrap=wrap, + _utest=_utest) return None From 45fb1a69581ffddb250a798d7b63e303567004e5 Mon Sep 17 00:00:00 2001 From: Tal Einat Date: Tue, 28 Aug 2018 15:21:55 +0300 Subject: [PATCH 06/16] ask for user confirmation before expanding huge squeezed outputs --- Lib/idlelib/idle_test/test_squeezer.py | 43 +++++++++++++++++++++++++- Lib/idlelib/squeezer.py | 37 +++++++++++++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/Lib/idlelib/idle_test/test_squeezer.py b/Lib/idlelib/idle_test/test_squeezer.py index 56dbf3ac4e9bfc..80dc58ddb0148a 100644 --- a/Lib/idlelib/idle_test/test_squeezer.py +++ b/Lib/idlelib/idle_test/test_squeezer.py @@ -490,7 +490,7 @@ def test_expand(self): retval = expandingbutton.expand(event=Mock()) self.assertEqual(retval, None) - # check that the text was inserting into the text widget + # check that the text was inserted into the text widget self.assertEqual(text_widget.get('1.0', 'end'), 'TEXT\n') # check that the 'TAGS' tag was set on the inserted text @@ -503,6 +503,47 @@ def test_expand(self): self.assertEqual(squeezer.expandingbuttons.remove.call_count, 1) squeezer.expandingbuttons.remove.assert_called_with(expandingbutton) + def test_expand_dangerous_oupput(self): + """attempting to expand very long output asks user for confirmation""" + squeezer = self.make_mock_squeezer() + text = 'a' * 10**5 + expandingbutton = ExpandingButton(text, 'TAGS', 30, squeezer) + expandingbutton.set_is_dangerous() + self.assertTrue(expandingbutton.is_dangerous) + + # 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 + + # patch the message box module to always return False + with patch('idlelib.squeezer.tkMessageBox') as mock_msgbox: + mock_msgbox.askokcancel.return_value = False + mock_msgbox.askyesno.return_value = False + + # trigger the expand event + retval = expandingbutton.expand(event=Mock()) + + # check that the event chain was broken and no text was inserted + self.assertEqual(retval, 'break') + self.assertEqual(expandingbutton.text.get('1.0', 'end-1c'), '') + + # patch the message box module to always return True + with patch('idlelib.squeezer.tkMessageBox') as mock_msgbox: + mock_msgbox.askokcancel.return_value = True + mock_msgbox.askyesno.return_value = True + + # trigger the expand event + retval = expandingbutton.expand(event=Mock()) + + # check that the event chain wasn't broken and the text was inserted + self.assertEqual(retval, None) + self.assertEqual(expandingbutton.text.get('1.0', 'end-1c'), text) + def test_copy(self): """test the copy event""" # testing with the actual clipboard proved problematic, so this test diff --git a/Lib/idlelib/squeezer.py b/Lib/idlelib/squeezer.py index a0099fecd7a616..81e8ceae689277 100644 --- a/Lib/idlelib/squeezer.py +++ b/Lib/idlelib/squeezer.py @@ -18,6 +18,7 @@ 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 @@ -106,6 +107,7 @@ class ExpandingButton(tk.Button): 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 @@ -114,7 +116,7 @@ def __init__(self, s, tags, numoflines, squeezer): # before the iomark self.base_text = editwin.per.bottom - button_text = "Squeezed text (%d lines)." % numoflines + button_text = "Squeezed text (%d lines)." % self.numoflines tk.Button.__init__(self, text, text=button_text, background="#FFFFC0", activebackground="#FFFFE0") @@ -132,12 +134,45 @@ def __init__(self, s, tags, numoflines, squeezer): self.selection_handle( 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): """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 preview 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) From 18106e8e08d46c48eaee2dd981180e74752c9070 Mon Sep 17 00:00:00 2001 From: Tal Einat Date: Tue, 28 Aug 2018 15:26:12 +0300 Subject: [PATCH 07/16] increase Squeezer's default auto-squeeze-min-lines from 30 to 50 --- Lib/idlelib/config-main.def | 2 +- Lib/idlelib/idle_test/test_squeezer.py | 20 ++++++++++---------- Lib/idlelib/squeezer.py | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Lib/idlelib/config-main.def b/Lib/idlelib/config-main.def index 8142eff89d99f8..535436b68d63c5 100644 --- a/Lib/idlelib/config-main.def +++ b/Lib/idlelib/config-main.def @@ -67,7 +67,7 @@ font-bold= 0 encoding= none [PyShell] -auto-squeeze-min-lines= 30 +auto-squeeze-min-lines= 50 show-squeezed-tooltips= 1 squeezed-tooltips-delay= 0 diff --git a/Lib/idlelib/idle_test/test_squeezer.py b/Lib/idlelib/idle_test/test_squeezer.py index 80dc58ddb0148a..f9f0cecd4e4fea 100644 --- a/Lib/idlelib/idle_test/test_squeezer.py +++ b/Lib/idlelib/idle_test/test_squeezer.py @@ -176,7 +176,7 @@ def test_write_stdout(self): for text in ['', 'TEXT']: editwin.write = orig_write = Mock(return_value=SENTINEL_VALUE) squeezer = self.make_squeezer_instance(editwin) - squeezer.auto_squeeze_min_lines = 30 + squeezer.auto_squeeze_min_lines = 50 self.assertEqual(squeezer.editwin.write(text, "stdout"), SENTINEL_VALUE) @@ -187,7 +187,7 @@ def test_write_stdout(self): 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.auto_squeeze_min_lines = 30 + squeezer.auto_squeeze_min_lines = 50 self.assertEqual(squeezer.editwin.write(text, "stdout"), None) self.assertEqual(orig_write.call_count, 0) @@ -425,7 +425,7 @@ def make_mock_squeezer(self): squeezer.editwin.text = Text() # Set default values for the configuration settings - squeezer.auto_squeeze_min_lines = 30 + squeezer.auto_squeeze_min_lines = 50 squeezer.should_show_tooltip = False squeezer.tooltip_delay = 1500 return squeezer @@ -437,12 +437,12 @@ def test_init_no_tooltip(self, MockHovertip): squeezer.should_show_tooltip = False text_widget = squeezer.editwin.text - expandingbutton = ExpandingButton('TEXT', 'TAGS', 30, squeezer) + expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer) self.assertEqual(expandingbutton.s, 'TEXT') # check that the underlying tkinter.Button is properly configured self.assertEqual(expandingbutton.master, text_widget) - self.assertTrue('30 lines' in expandingbutton.cget('text')) + self.assertTrue('50 lines' in expandingbutton.cget('text')) # check that the text widget still contains no text self.assertEqual(text_widget.get('1.0', 'end'), '\n') @@ -461,7 +461,7 @@ def test_init_tooltip(self, MockHovertip): squeezer = self.make_mock_squeezer() squeezer.should_show_tooltip = True squeezer.tooltip_delay = SENTINEL_VALUE - expandingbutton = ExpandingButton('TEXT', 'TAGS', 30, squeezer) + expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer) # check that ToolTip was called once, with appropriate values self.assertEqual(MockHovertip.call_count, 1) @@ -475,7 +475,7 @@ def test_init_tooltip(self, MockHovertip): def test_expand(self): """test the expand event""" squeezer = self.make_mock_squeezer() - expandingbutton = ExpandingButton('TEXT', 'TAGS', 30, squeezer) + expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer) # insert the button into the text widget # (this is normally done by the Squeezer class) @@ -507,7 +507,7 @@ def test_expand_dangerous_oupput(self): """attempting to expand very long output asks user for confirmation""" squeezer = self.make_mock_squeezer() text = 'a' * 10**5 - expandingbutton = ExpandingButton(text, 'TAGS', 30, squeezer) + expandingbutton = ExpandingButton(text, 'TAGS', 50, squeezer) expandingbutton.set_is_dangerous() self.assertTrue(expandingbutton.is_dangerous) @@ -550,7 +550,7 @@ def test_copy(self): # 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 = ExpandingButton('TEXT', 'TAGS', 50, squeezer) expandingbutton.clipboard_clear = Mock() expandingbutton.clipboard_append = Mock() @@ -574,7 +574,7 @@ def _file_cleanup(self, filename): def test_preview(self): """test the preview event""" squeezer = self.make_mock_squeezer() - expandingbutton = ExpandingButton('TEXT', 'TAGS', 30, squeezer) + expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer) expandingbutton.selection_own = Mock() with patch('idlelib.squeezer.view_text', autospec=view_text)\ diff --git a/Lib/idlelib/squeezer.py b/Lib/idlelib/squeezer.py index 81e8ceae689277..10c17afd3defde 100644 --- a/Lib/idlelib/squeezer.py +++ b/Lib/idlelib/squeezer.py @@ -204,7 +204,7 @@ def reload(cls): """Load class variables from config.""" cls.auto_squeeze_min_lines = idleConf.GetOption( "main", "PyShell", "auto-squeeze-min-lines", - type="int", default=30, + type="int", default=50, ) cls.should_show_tooltip = idleConf.GetOption( "main", "PyShell", "show-squeezed-tooltips", From 2c795e3483c70265b374d62448942e762b337952 Mon Sep 17 00:00:00 2001 From: Tal Einat Date: Tue, 28 Aug 2018 17:24:10 +0300 Subject: [PATCH 08/16] move 'copy' and 'preview' squeezed output actions to a context menu --- Lib/idlelib/idle_test/test_squeezer.py | 28 +++++++++++++++-------- Lib/idlelib/squeezer.py | 31 ++++++++++++++++++++------ 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/Lib/idlelib/idle_test/test_squeezer.py b/Lib/idlelib/idle_test/test_squeezer.py index f9f0cecd4e4fea..772a10747c938c 100644 --- a/Lib/idlelib/idle_test/test_squeezer.py +++ b/Lib/idlelib/idle_test/test_squeezer.py @@ -8,6 +8,7 @@ from idlelib.config import idleConf from idlelib.squeezer import count_lines_with_wrapping, ExpandingButton, \ Squeezer +from idlelib import macosx from idlelib.textview import view_text from idlelib.tooltip import Hovertip from idlelib.pyshell import PyShell @@ -452,8 +453,8 @@ def test_init_no_tooltip(self, MockHovertip): # check that the mouse events are bound self.assertIn('', expandingbutton.bind()) - self.assertIn('', expandingbutton.bind()) - self.assertIn('', expandingbutton.bind()) + right_button_code = '' % ('2' if macosx.isAquaTk() else '3') + self.assertIn(right_button_code, expandingbutton.bind()) @patch('idlelib.squeezer.Hovertip', autospec=Hovertip) def test_init_tooltip(self, MockHovertip): @@ -564,13 +565,6 @@ def test_copy(self): self.assertEqual(expandingbutton.clipboard_append.call_count, 1) expandingbutton.clipboard_append.assert_called_with('TEXT') - def _file_cleanup(self, filename): - if os.path.exists(filename): - try: - os.remove(filename) - except OSError: - pass - def test_preview(self): """test the preview event""" squeezer = self.make_mock_squeezer() @@ -587,3 +581,19 @@ def test_preview(self): # check that the proper text was passed self.assertEqual(mock_view_text.call_args[0][2], 'TEXT') + + def test_rmenu(self): + """test the context menu""" + squeezer = self.make_mock_squeezer() + expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer) + with patch('tkinter.Menu') as mock_Menu: + mock_menu = Mock() + mock_Menu.return_value = mock_menu + mock_event = Mock() + mock_event.x = 10 + mock_event.y = 10 + expandingbutton.context_menu_event(event=mock_event) + self.assertEqual(mock_menu.add_command.call_count, + len(expandingbutton.rmenu_specs)) + for label, *data in expandingbutton.rmenu_specs: + mock_menu.add_command.assert_any_call(label=label, command=ANY) diff --git a/Lib/idlelib/squeezer.py b/Lib/idlelib/squeezer.py index 10c17afd3defde..c2c7510347c6d4 100644 --- a/Lib/idlelib/squeezer.py +++ b/Lib/idlelib/squeezer.py @@ -23,6 +23,7 @@ from idlelib.config import idleConf from idlelib.textview import view_text from idlelib.tooltip import Hovertip +from idlelib import macosx def _add_to_rmenu(editwin, specs): @@ -122,15 +123,17 @@ def __init__(self, s, tags, numoflines, squeezer): if self.squeezer.should_show_tooltip: button_tooltip_text = ( - "Double-click to expand, middle-click to copy, " + - "right-click to preview." + "Double-click to expand, right-click for more options." ) Hovertip(self, button_tooltip_text, hover_delay=self.squeezer.tooltip_delay) self.bind("", self.expand) - self.bind("", self.copy) - self.bind("", self.preview) + if macosx.isAquaTk(): + # AquaTk defines <2> as the right button, not <3>. + self.bind("", self.context_menu_event) + else: + self.bind("", self.context_menu_event) self.selection_handle( lambda offset, length: s[int(offset):int(offset) + int(length)]) @@ -148,7 +151,7 @@ def set_is_dangerous(self): ) ) - def expand(self, event): + def expand(self, event=None): """expand event handler This inserts the original text in place of the button in the Text @@ -177,7 +180,7 @@ def expand(self, event): self.base_text.delete(self) self.squeezer.expandingbuttons.remove(self) - def copy(self, event): + def copy(self, event=None): """copy event handler Copy the original text to the clipboard. @@ -185,13 +188,27 @@ def copy(self, event): self.clipboard_clear() self.clipboard_append(self.s) - def preview(self, event): + def preview(self, event=None): """preview event handler View the original text in a separate text viewer window. """ view_text(self.text, "Squeezed Output Viewer", self.s, wrap='none') + rmenu_specs = ( + # item structure: (label, method_name) + ('copy', 'copy'), + ('preview', 'preview'), + ) + + 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. From 1a1288a7019c8b12055e6014d32ae29a30f901b1 Mon Sep 17 00:00:00 2001 From: Tal Einat Date: Tue, 28 Aug 2018 17:53:30 +0300 Subject: [PATCH 09/16] Squeezer: rename "preview" to "view" --- Lib/idlelib/idle_test/test_squeezer.py | 41 +++++++++++++------------- Lib/idlelib/squeezer.py | 18 +++++------ 2 files changed, 29 insertions(+), 30 deletions(-) diff --git a/Lib/idlelib/idle_test/test_squeezer.py b/Lib/idlelib/idle_test/test_squeezer.py index 772a10747c938c..6e52cb53e0ae22 100644 --- a/Lib/idlelib/idle_test/test_squeezer.py +++ b/Lib/idlelib/idle_test/test_squeezer.py @@ -1,4 +1,3 @@ -import os from collections import namedtuple from tkinter import Text import unittest @@ -250,22 +249,22 @@ def test_expand_last_squeezed_event(self): self.assertEqual(retval, "break") self.assertEqual(squeezer.text.bell.call_count, 1) - def test_preview_last_squeezed_event_no_squeezed(self): - """test the preview_last_squeezed event""" + def test_view_last_squeezed_event_no_squeezed(self): + """test the view_last_squeezed event""" # The tested scenario: There are no squeezed texts, therefore there - # are no ExpandingButton instances. The preview_last_squeezed event + # are no ExpandingButton instances. The view_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.preview_last_squeezed_event(event=Mock()) + retval = squeezer.view_last_squeezed_event(event=Mock()) self.assertEqual(retval, "break") - def test_preview_last_squeezed_event(self): - """test the preview_last_squeezed event""" + def test_view_last_squeezed_event(self): + """test the view_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 + # are two ExpandingButton instances. The view_last_squeezed event + # is called twice. Both times should call the view() method of the # second ExpandingButton. editwin = self.make_mock_editor_window() squeezer = self.make_squeezer_instance(editwin) @@ -274,17 +273,17 @@ def test_preview_last_squeezed_event(self): squeezer.expandingbuttons = [mock_expandingbutton1, mock_expandingbutton2] - # check that the second expanding button is previewed - retval = squeezer.preview_last_squeezed_event(event=SENTINEL_VALUE) + # check that the second expanding button is viewed + retval = squeezer.view_last_squeezed_event(event=SENTINEL_VALUE) self.assertEqual(retval, "break") self.assertEqual(squeezer.text.bell.call_count, 0) - self.assertEqual(mock_expandingbutton1.preview.call_count, 0) - self.assertEqual(mock_expandingbutton2.preview.call_count, 1) - mock_expandingbutton2.preview.assert_called_with(SENTINEL_VALUE) + self.assertEqual(mock_expandingbutton1.view.call_count, 0) + self.assertEqual(mock_expandingbutton2.view.call_count, 1) + mock_expandingbutton2.view.assert_called_with(SENTINEL_VALUE) - squeezer.preview_last_squeezed_event(event=SENTINEL_VALUE) - self.assertEqual(mock_expandingbutton1.preview.call_count, 0) - self.assertEqual(mock_expandingbutton2.preview.call_count, 2) + squeezer.view_last_squeezed_event(event=SENTINEL_VALUE) + self.assertEqual(mock_expandingbutton1.view.call_count, 0) + self.assertEqual(mock_expandingbutton2.view.call_count, 2) def test_auto_squeeze(self): """test that the auto-squeezing creates an ExpandingButton properly""" @@ -565,16 +564,16 @@ def test_copy(self): self.assertEqual(expandingbutton.clipboard_append.call_count, 1) expandingbutton.clipboard_append.assert_called_with('TEXT') - def test_preview(self): - """test the preview event""" + def test_view(self): + """test the view event""" squeezer = self.make_mock_squeezer() expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer) expandingbutton.selection_own = Mock() with patch('idlelib.squeezer.view_text', autospec=view_text)\ as mock_view_text: - # trigger the preview event - expandingbutton.preview(event=Mock()) + # trigger the view event + expandingbutton.view(event=Mock()) # check that the expanding button called view_text self.assertEqual(mock_view_text.call_count, 1) diff --git a/Lib/idlelib/squeezer.py b/Lib/idlelib/squeezer.py index c2c7510347c6d4..13ac819ebe94ce 100644 --- a/Lib/idlelib/squeezer.py +++ b/Lib/idlelib/squeezer.py @@ -99,8 +99,8 @@ class ExpandingButton(tk.Button): 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 a - separate window. + 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). @@ -168,7 +168,7 @@ def expand(self, event=None): 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 preview or copy the output instead.", + "It is recommended to view or copy the output instead.", "Really expand?" ]) % (self.numoflines, len(self.s)), default=tkMessageBox.CANCEL, @@ -188,8 +188,8 @@ def copy(self, event=None): self.clipboard_clear() self.clipboard_append(self.s) - def preview(self, event=None): - """preview event handler + def view(self, event=None): + """view event handler View the original text in a separate text viewer window. """ @@ -198,7 +198,7 @@ def preview(self, event=None): rmenu_specs = ( # item structure: (label, method_name) ('copy', 'copy'), - ('preview', 'preview'), + ('view', 'view'), ) def context_menu_event(self, event): @@ -329,8 +329,8 @@ def expand_last_squeezed_event(self, event): self.text.bell() return "break" - def preview_last_squeezed_event(self, event): - """preview-last-squeezed event handler + def view_last_squeezed_event(self, event): + """view-last-squeezed event handler Preview the last squeezed text in the Text widget. @@ -338,7 +338,7 @@ def preview_last_squeezed_event(self, event): do nothing. """ if len(self.expandingbuttons) > 0: - self.expandingbuttons[-1].preview(event) + self.expandingbuttons[-1].view(event) else: self.text.bell() return "break" From f753b374db6d2206994de79ce6422e7b32095a34 Mon Sep 17 00:00:00 2001 From: Tal Einat Date: Thu, 30 Aug 2018 11:41:55 +0300 Subject: [PATCH 10/16] Squeezer: remove tooltip configuration options (delay set to 80ms) --- Lib/idlelib/config-main.def | 2 -- Lib/idlelib/configdialog.py | 43 ++------------------------ Lib/idlelib/idle_test/test_squeezer.py | 19 ++---------- Lib/idlelib/squeezer.py | 29 +++-------------- 4 files changed, 8 insertions(+), 85 deletions(-) diff --git a/Lib/idlelib/config-main.def b/Lib/idlelib/config-main.def index 535436b68d63c5..06e3c5adb0e35b 100644 --- a/Lib/idlelib/config-main.def +++ b/Lib/idlelib/config-main.def @@ -68,8 +68,6 @@ encoding= none [PyShell] auto-squeeze-min-lines= 50 -show-squeezed-tooltips= 1 -squeezed-tooltips-delay= 0 [Indent] use-spaces= 1 diff --git a/Lib/idlelib/configdialog.py b/Lib/idlelib/configdialog.py index be6d73463426aa..229dc898743322 100644 --- a/Lib/idlelib/configdialog.py +++ b/Lib/idlelib/configdialog.py @@ -1823,12 +1823,6 @@ def create_page_general(self): frame_auto_squeeze_min_lines: Frame auto_squeeze_min_lines_title: Label (*)auto_squeeze_min_lines_int: Entry - auto_squeeze_min_lines - frame_show_squeezed_tooltips: Frame - show_squeezed_tooltips_title: Label - (*)show_squeezed_tooltips_bool: Entry - show_squeezed_tooltips - frame_squeezed_tooltips_delay: Frame - squeezed_tooltips_delay_title: Label - (*)squeezed_tooltips_delay_int: Entry - squeezed_tooltips_delay frame_help: LabelFrame frame_helplist: Frame frame_helplist_buttons: Frame @@ -1856,10 +1850,6 @@ def create_page_general(self): self.auto_squeeze_min_lines = tracers.add( StringVar(self), ('main', 'PyShell', 'auto-squeeze-min-lines')) - self.show_squeezed_tooltips = tracers.add( - StringVar(self), ('main', 'PyShell', 'show-squeezed-tooltips')) - self.squeezed_tooltips_delay = tracers.add( - StringVar(self), ('main', 'PyShell', 'squeezed-tooltips-delay')) self.autosave = tracers.add( IntVar(self), ('main', 'General', 'autosave')) @@ -1946,18 +1936,6 @@ def create_page_general(self): self.auto_squeeze_min_lines_int = Entry( frame_auto_squeeze_min_lines, width=4, textvariable=self.auto_squeeze_min_lines) - frame_show_squeezed_tooltips = Frame(frame_shell, borderwidth=0) - show_squeezed_tooltips_title = Label(frame_show_squeezed_tooltips, - text='Show Squeezed Tooltips:') - self.show_squeezed_tooltips_on = Checkbutton( - frame_show_squeezed_tooltips, - variable=self.show_squeezed_tooltips) - frame_squeezed_tooltips_delay = Frame(frame_shell, borderwidth=0) - squeezed_tooltips_delay_title = Label(frame_squeezed_tooltips_delay, - text='Squeezed Tooltip Delay:') - self.squeezed_tooltips_delay_int = Entry( - frame_squeezed_tooltips_delay, width=4, - textvariable=self.squeezed_tooltips_delay) # frame_help. frame_helplist = Frame(frame_help) @@ -2029,14 +2007,6 @@ def create_page_general(self): auto_squeeze_min_lines_title.pack(side=LEFT, anchor=W, padx=5, pady=5) self.auto_squeeze_min_lines_int.pack(side=TOP, padx=5, pady=5) - frame_show_squeezed_tooltips.pack(side=TOP, padx=5, pady=0, fill=X) - show_squeezed_tooltips_title.pack(side=LEFT, anchor=W, padx=5, pady=5) - self.show_squeezed_tooltips_on.pack(side=TOP, padx=5, pady=5) - - frame_squeezed_tooltips_delay.pack(side=TOP, padx=5, pady=0, fill=X) - squeezed_tooltips_delay_title.pack(side=LEFT, anchor=W, padx=5, pady=5) - self.squeezed_tooltips_delay_int.pack(side=TOP, padx=5, pady=5) - # frame_help. frame_helplist_buttons.pack(side=RIGHT, padx=5, pady=5, fill=Y) frame_helplist.pack(side=TOP, padx=5, pady=5, expand=TRUE, fill=BOTH) @@ -2075,10 +2045,6 @@ def load_general_cfg(self): # Set variables for shell windows. self.auto_squeeze_min_lines.set(idleConf.GetOption( 'main', 'PyShell', 'auto-squeeze-min-lines', type='int')) - self.show_squeezed_tooltips.set(idleConf.GetOption( - 'main', 'PyShell', 'show-squeezed-tooltips', type='bool')) - self.squeezed_tooltips_delay.set(idleConf.GetOption( - 'main', 'PyShell', 'squeezed-tooltips-delay', type='int')) # Set additional help sources. self.user_helplist = idleConf.GetAllExtraHelpSourcesList() @@ -2274,13 +2240,8 @@ def detach(self): CodeContext: Maxlines is the maximum number of code context lines to display when Code Context is turned on for an editor window. -Shell Preferences: -* Auto-Squeeze Min. Lines is the minimum number of lines of output to - automatically "squeeze". -* Show Squeezed Tooltips toggles whether tooltips are shown for squeezed - outputs. -* Squeezed Tooltips Delay sets the mouse hover delay over a squeezed output - before its tooltip is shown, in milliseconds. +Shell Preferences: Auto-Squeeze Min. Lines is the minimum number of lines +of output to automatically "squeeze". ''' } diff --git a/Lib/idlelib/idle_test/test_squeezer.py b/Lib/idlelib/idle_test/test_squeezer.py index 6e52cb53e0ae22..f34e5f2e2982d8 100644 --- a/Lib/idlelib/idle_test/test_squeezer.py +++ b/Lib/idlelib/idle_test/test_squeezer.py @@ -426,15 +426,12 @@ def make_mock_squeezer(self): # Set default values for the configuration settings squeezer.auto_squeeze_min_lines = 50 - squeezer.should_show_tooltip = False - squeezer.tooltip_delay = 1500 return squeezer @patch('idlelib.squeezer.Hovertip', autospec=Hovertip) - def test_init_no_tooltip(self, MockHovertip): + def test_init(self, MockHovertip): """Test the simplest creation of an ExpandingButton""" squeezer = self.make_mock_squeezer() - squeezer.should_show_tooltip = False text_widget = squeezer.editwin.text expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer) @@ -447,26 +444,14 @@ def test_init_no_tooltip(self, MockHovertip): # check that the text widget still contains no text self.assertEqual(text_widget.get('1.0', 'end'), '\n') - # check that no tooltip was created - self.assertEqual(MockHovertip.call_count, 0) - # check that the mouse events are bound self.assertIn('', expandingbutton.bind()) right_button_code = '' % ('2' if macosx.isAquaTk() else '3') self.assertIn(right_button_code, expandingbutton.bind()) - @patch('idlelib.squeezer.Hovertip', autospec=Hovertip) - def test_init_tooltip(self, MockHovertip): - """test tooltip creation""" - squeezer = self.make_mock_squeezer() - squeezer.should_show_tooltip = True - squeezer.tooltip_delay = SENTINEL_VALUE - expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer) - # check that ToolTip was called once, with appropriate values self.assertEqual(MockHovertip.call_count, 1) - MockHovertip.assert_called_with(expandingbutton, ANY, - hover_delay=SENTINEL_VALUE) + MockHovertip.assert_called_with(expandingbutton, ANY, hover_delay=ANY) # check that 'right-click' appears in the tooltip text tooltip_text = MockHovertip.call_args[0][1] diff --git a/Lib/idlelib/squeezer.py b/Lib/idlelib/squeezer.py index 13ac819ebe94ce..704bdce75be3ad 100644 --- a/Lib/idlelib/squeezer.py +++ b/Lib/idlelib/squeezer.py @@ -26,15 +26,6 @@ from idlelib import macosx -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. @@ -92,8 +83,6 @@ def count_lines_with_wrapping(s, linewidth=80, tabwidth=8): return linecount -# define the extension's classes - class ExpandingButton(tk.Button): """Class for the "squeezed" text buttons used by Squeezer @@ -121,12 +110,10 @@ def __init__(self, s, tags, numoflines, squeezer): tk.Button.__init__(self, text, text=button_text, background="#FFFFC0", activebackground="#FFFFE0") - if self.squeezer.should_show_tooltip: - button_tooltip_text = ( - "Double-click to expand, right-click for more options." - ) - Hovertip(self, button_tooltip_text, - hover_delay=self.squeezer.tooltip_delay) + button_tooltip_text = ( + "Double-click to expand, right-click for more options." + ) + Hovertip(self, button_tooltip_text, hover_delay=80) self.bind("", self.expand) if macosx.isAquaTk(): @@ -223,14 +210,6 @@ def reload(cls): "main", "PyShell", "auto-squeeze-min-lines", type="int", default=50, ) - cls.should_show_tooltip = idleConf.GetOption( - "main", "PyShell", "show-squeezed-tooltips", - type="bool", default=True, - ) - cls.tooltip_delay = idleConf.GetOption( - "main", "PyShell", "squeezed-tooltips-delay", - type="int", default=0, - ) def __init__(self, editwin): """Initialize settings for Squeezer. From 6087d3f604623c0b402de0304505f02eb48d9032 Mon Sep 17 00:00:00 2001 From: Tal Einat Date: Thu, 30 Aug 2018 11:47:07 +0300 Subject: [PATCH 11/16] Squeezer: remove expand/view-last-squeezed events --- Lib/idlelib/idle_test/test_squeezer.py | 92 -------------------------- Lib/idlelib/pyshell.py | 12 ---- Lib/idlelib/squeezer.py | 28 -------- 3 files changed, 132 deletions(-) diff --git a/Lib/idlelib/idle_test/test_squeezer.py b/Lib/idlelib/idle_test/test_squeezer.py index f34e5f2e2982d8..cb5c2858dbed64 100644 --- a/Lib/idlelib/idle_test/test_squeezer.py +++ b/Lib/idlelib/idle_test/test_squeezer.py @@ -193,98 +193,6 @@ def test_write_stdout(self): self.assertEqual(orig_write.call_count, 0) self.assertEqual(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.assertEqual(retval, "break") - self.assertEqual(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.assertEqual(retval, "break") - self.assertEqual(squeezer.text.bell.call_count, 0) - self.assertEqual(mock_expandingbutton1.expand.call_count, 0) - self.assertEqual(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.assertEqual(retval, "break") - self.assertEqual(squeezer.text.bell.call_count, 0) - self.assertEqual(mock_expandingbutton1.expand.call_count, 1) - self.assertEqual(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.assertEqual(retval, "break") - self.assertEqual(squeezer.text.bell.call_count, 1) - - def test_view_last_squeezed_event_no_squeezed(self): - """test the view_last_squeezed event""" - # The tested scenario: There are no squeezed texts, therefore there - # are no ExpandingButton instances. The view_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.view_last_squeezed_event(event=Mock()) - self.assertEqual(retval, "break") - - def test_view_last_squeezed_event(self): - """test the view_last_squeezed event""" - # The tested scenario: There are two squeezed texts, therefore there - # are two ExpandingButton instances. The view_last_squeezed event - # is called twice. Both times should call the view() method of the - # second ExpandingButton. - 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 viewed - retval = squeezer.view_last_squeezed_event(event=SENTINEL_VALUE) - self.assertEqual(retval, "break") - self.assertEqual(squeezer.text.bell.call_count, 0) - self.assertEqual(mock_expandingbutton1.view.call_count, 0) - self.assertEqual(mock_expandingbutton2.view.call_count, 1) - mock_expandingbutton2.view.assert_called_with(SENTINEL_VALUE) - - squeezer.view_last_squeezed_event(event=SENTINEL_VALUE) - self.assertEqual(mock_expandingbutton1.view.call_count, 0) - self.assertEqual(mock_expandingbutton2.view.call_count, 2) - def test_auto_squeeze(self): """test that the auto-squeezing creates an ExpandingButton properly""" requires('gui') diff --git a/Lib/idlelib/pyshell.py b/Lib/idlelib/pyshell.py index dcd822d0ff9b39..5458c59dbd7e0b 100755 --- a/Lib/idlelib/pyshell.py +++ b/Lib/idlelib/pyshell.py @@ -856,21 +856,11 @@ class PyShell(OutputWindow): ("help", "_Help"), ] - # Extend menus - menu_extras = [ - ('edit', [ - None, # Separator - ("Expand last squeezed text", "<>"), - ("Preview last squeezed text", "<>"), - ]), - ] - # Extend right-click context menu rmenu_specs = OutputWindow.rmenu_specs + [ ("Squeeze", "<>"), ] - # New classes from idlelib.history import History @@ -888,8 +878,6 @@ def __init__(self, flist=None): OutputWindow.__init__(self, flist, None, None) - self.fill_menus(self.menu_extras) - self.usetabs = True # indentwidth must be 8 when using tabs. See note in EditorWindow: self.indentwidth = 8 diff --git a/Lib/idlelib/squeezer.py b/Lib/idlelib/squeezer.py index 704bdce75be3ad..aed96fdbe7366f 100644 --- a/Lib/idlelib/squeezer.py +++ b/Lib/idlelib/squeezer.py @@ -294,34 +294,6 @@ def count_lines(self, s): 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 view_last_squeezed_event(self, event): - """view-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 len(self.expandingbuttons) > 0: - self.expandingbuttons[-1].view(event) - else: - self.text.bell() - return "break" - def squeeze_current_text_event(self, event): """squeeze-current-text event handler From b7d27cb832a3bd2c5d7cebae01e529c6ea39aa7f Mon Sep 17 00:00:00 2001 From: Tal Einat Date: Thu, 30 Aug 2018 12:26:20 +0300 Subject: [PATCH 12/16] Squeezer: make view window non-modal and add horizontal scrollbar --- Lib/idlelib/squeezer.py | 3 ++- Lib/idlelib/textview.py | 44 +++++++++++++++++++++++++++++++++++------ 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/Lib/idlelib/squeezer.py b/Lib/idlelib/squeezer.py index aed96fdbe7366f..f5aac813a15933 100644 --- a/Lib/idlelib/squeezer.py +++ b/Lib/idlelib/squeezer.py @@ -180,7 +180,8 @@ def view(self, event=None): View the original text in a separate text viewer window. """ - view_text(self.text, "Squeezed Output Viewer", self.s, wrap='none') + view_text(self.text, "Squeezed Output Viewer", self.s, + modal=False, wrap='none') rmenu_specs = ( # item structure: (label, method_name) diff --git a/Lib/idlelib/textview.py b/Lib/idlelib/textview.py index 9e862eea6fb4cc..43a872bf60f861 100644 --- a/Lib/idlelib/textview.py +++ b/Lib/idlelib/textview.py @@ -1,11 +1,31 @@ """Simple text browser for IDLE """ -from tkinter import Toplevel, Text +from tkinter import Toplevel, Text, TclError,\ + HORIZONTAL, VERTICAL, N, S, E, W from tkinter.ttk import Frame, Scrollbar, Button from tkinter.messagebox import showerror +class AutoHiddenScrollbar(Scrollbar): + """A scrollbar that is automatically hidden when not needed. + + Only the grid geometry manager is supported. + """ + def set(self, lo, hi): + if float(lo) > 0.0 or float(hi) < 1.0: + self.grid() + else: + self.grid_remove() + super().set(lo, hi) + + def pack(self, **kwargs): + raise TclError(f'{self.__class__.__name__} does not support "pack"') + + def place(self, **kwargs): + raise TclError(f'{self.__class__.__name__} does not support "place"') + + class TextFrame(Frame): "Display text with scrollbar." @@ -24,15 +44,27 @@ def __init__(self, parent, rawtext, wrap='word'): self.text = text = Text(self, wrap=wrap, highlightthickness=0, fg=self.fg, bg=self.bg) - self.scroll = scroll = Scrollbar(self, orient='vertical', - takefocus=False, command=text.yview) - text['yscrollcommand'] = scroll.set + text.grid(row=0, column=0, sticky=N+S+E+W) + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) text.insert(0.0, rawtext) text['state'] = 'disabled' text.focus_set() - scroll.pack(side='right', fill='y') - text.pack(side='left', expand=True, fill='both') + # vertical scrollbar + self.yscroll = yscroll = AutoHiddenScrollbar(self, orient=VERTICAL, + takefocus=False, + command=text.yview) + text['yscrollcommand'] = yscroll.set + yscroll.grid(row=0, column=1, sticky=N+S) + + if wrap == 'none': + # horizontal scrollbar + self.xscroll = xscroll = AutoHiddenScrollbar(self, orient=HORIZONTAL, + takefocus=False, + command=text.xview) + text['xscrollcommand'] = xscroll.set + xscroll.grid(row=1, column=0, sticky=E+W) class ViewFrame(Frame): From 9676991ac8020d65cc776e3f335d41ba83bf8668 Mon Sep 17 00:00:00 2001 From: Terry Jan Reedy Date: Mon, 24 Sep 2018 22:21:59 -0400 Subject: [PATCH 13/16] Correct error in merge resolution. --- Lib/idlelib/textview.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/idlelib/textview.py b/Lib/idlelib/textview.py index 7ca77b449cd3cc..4867a80db1abe6 100644 --- a/Lib/idlelib/textview.py +++ b/Lib/idlelib/textview.py @@ -41,7 +41,7 @@ def __init__(self, parent, rawtext, wrap='word'): self['relief'] = 'sunken' self['height'] = 700 - self.text = text = Text(self, wrap='word', highlightthickness=0) + self.text = text = Text(self, wrap=wrap, highlightthickness=0) color_config(text) text.grid(row=0, column=0, sticky=N+S+E+W) self.grid_rowconfigure(0, weight=1) From e768e0fc460970b3ca37cf2eeb6e1fbe7eca6216 Mon Sep 17 00:00:00 2001 From: Terry Jan Reedy Date: Mon, 24 Sep 2018 22:27:58 -0400 Subject: [PATCH 14/16] Make test_squeezer runnable. --- Lib/idlelib/idle_test/test_squeezer.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Lib/idlelib/idle_test/test_squeezer.py b/Lib/idlelib/idle_test/test_squeezer.py index cb5c2858dbed64..20ca8cca47b9f6 100644 --- a/Lib/idlelib/idle_test/test_squeezer.py +++ b/Lib/idlelib/idle_test/test_squeezer.py @@ -489,3 +489,7 @@ def test_rmenu(self): len(expandingbutton.rmenu_specs)) for label, *data in expandingbutton.rmenu_specs: mock_menu.add_command.assert_any_call(label=label, command=ANY) + + +if __name__ == '__main__': + unittest.main(verbosity=2) From fcc9084f3876693b02947e36db8bfdebc5f4e33d Mon Sep 17 00:00:00 2001 From: Tal Einat Date: Tue, 25 Sep 2018 14:37:49 +0300 Subject: [PATCH 15/16] bpo-1529353: explicitly create root Tk() objects in tests --- Lib/idlelib/idle_test/test_squeezer.py | 38 ++++++++++++++++++-------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/Lib/idlelib/idle_test/test_squeezer.py b/Lib/idlelib/idle_test/test_squeezer.py index 20ca8cca47b9f6..d8e73441ba6e21 100644 --- a/Lib/idlelib/idle_test/test_squeezer.py +++ b/Lib/idlelib/idle_test/test_squeezer.py @@ -1,5 +1,5 @@ from collections import namedtuple -from tkinter import Text +from tkinter import Text, Tk import unittest from unittest.mock import Mock, NonCallableMagicMock, patch, sentinel, ANY from test.support import requires @@ -16,6 +16,20 @@ SENTINEL_VALUE = sentinel.SENTINEL_VALUE +def get_test_tk_root(test_instance): + """helper for tests: create a root Tk object""" + requires('gui') + root = Tk() + root.withdraw() + + def cleanup_root(): + root.update_idletasks() + root.destroy() + test_instance.addCleanup(cleanup_root) + + return root + + class TestCountLines(unittest.TestCase): """tests for the count_lines_with_wrapping function""" def check(self, expected, text, linewidth, tabwidth): @@ -195,8 +209,8 @@ def test_write_stdout(self): def test_auto_squeeze(self): """test that the auto-squeezing creates an ExpandingButton properly""" - requires('gui') - text_widget = Text() + root = get_test_tk_root(self) + text_widget = Text(root) text_widget.mark_set("iomark", "1.0") editwin = self.make_mock_editor_window() @@ -211,11 +225,11 @@ def test_auto_squeeze(self): def test_squeeze_current_text_event(self): """test the squeeze_current_text event""" - requires('gui') + root = get_test_tk_root(self) # squeezing text should work for both stdout and stderr - for tag_name in "stdout", "stderr": - text_widget = Text() + for tag_name in ["stdout", "stderr"]: + text_widget = Text(root) text_widget.mark_set("iomark", "1.0") editwin = self.make_mock_editor_window() @@ -245,9 +259,9 @@ def test_squeeze_current_text_event(self): def test_squeeze_current_text_event_no_allowed_tags(self): """test that the event doesn't squeeze text without a relevant tag""" - requires('gui') + root = get_test_tk_root(self) - text_widget = Text() + text_widget = Text(root) text_widget.mark_set("iomark", "1.0") editwin = self.make_mock_editor_window() @@ -270,9 +284,9 @@ def test_squeeze_current_text_event_no_allowed_tags(self): def test_squeeze_text_before_existing_squeezed_text(self): """test squeezing text before existing squeezed text""" - requires('gui') + root = get_test_tk_root(self) - text_widget = Text() + text_widget = Text(root) text_widget.mark_set("iomark", "1.0") editwin = self.make_mock_editor_window() @@ -328,9 +342,9 @@ class TestExpandingButton(unittest.TestCase): # Text and Button instances are created. def make_mock_squeezer(self): """helper for tests""" - requires('gui') + root = get_test_tk_root(self) squeezer = Mock() - squeezer.editwin.text = Text() + squeezer.editwin.text = Text(root) # Set default values for the configuration settings squeezer.auto_squeeze_min_lines = 50 From 468d9ecdebe7cf15327f0d1f9838697fd0e8b254 Mon Sep 17 00:00:00 2001 From: Tal Einat Date: Tue, 25 Sep 2018 14:54:06 +0300 Subject: [PATCH 16/16] bpo-1529353: reformat doc-strings as PEP8 and rename test classes --- Lib/idlelib/idle_test/test_squeezer.py | 66 +++++++++++++------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/Lib/idlelib/idle_test/test_squeezer.py b/Lib/idlelib/idle_test/test_squeezer.py index d8e73441ba6e21..ca8b674cc236dd 100644 --- a/Lib/idlelib/idle_test/test_squeezer.py +++ b/Lib/idlelib/idle_test/test_squeezer.py @@ -17,7 +17,7 @@ def get_test_tk_root(test_instance): - """helper for tests: create a root Tk object""" + """Helper for tests: Create a root Tk object.""" requires('gui') root = Tk() root.withdraw() @@ -30,8 +30,8 @@ def cleanup_root(): return root -class TestCountLines(unittest.TestCase): - """tests for the count_lines_with_wrapping function""" +class CountLinesTest(unittest.TestCase): + """Tests for the count_lines_with_wrapping function.""" def check(self, expected, text, linewidth, tabwidth): return self.assertEqual( expected, @@ -39,23 +39,23 @@ def check(self, expected, text, linewidth, tabwidth): ) def test_count_empty(self): - """test with an empty string""" + """Test with an empty string.""" self.assertEqual(count_lines_with_wrapping(""), 0) def test_count_begins_with_empty_line(self): - """test with a string which begins with a newline""" + """Test with a string which begins with a newline.""" self.assertEqual(count_lines_with_wrapping("\ntext"), 2) def test_count_ends_with_empty_line(self): - """test with a string which ends with a newline""" + """Test with a string which ends with a newline.""" self.assertEqual(count_lines_with_wrapping("text\n"), 1) def test_count_several_lines(self): - """test with several lines of text""" + """Test with several lines of text.""" self.assertEqual(count_lines_with_wrapping("1\n2\n3\n"), 3) def test_tab_width(self): - """test with various tab widths and line widths""" + """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) @@ -78,10 +78,10 @@ def test_tab_width(self): self.check(expected=2, text='\t' * 6, linewidth=13, tabwidth=4) -class TestSqueezer(unittest.TestCase): - """tests for the Squeezer class""" +class SqueezerTest(unittest.TestCase): + """Tests for the Squeezer class.""" def make_mock_editor_window(self): - """create a mock EditorWindow instance""" + """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 @@ -89,13 +89,13 @@ def make_mock_editor_window(self): return editwin def make_squeezer_instance(self, editor_window=None): - """create an actual Squeezer instance with a mock EditorWindow""" + """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 + """Test Squeezer.count_lines() with various inputs. This checks that Squeezer.count_lines() calls the count_lines_with_wrapping() function with the appropriate parameters. @@ -106,7 +106,7 @@ def test_count_lines(self): def _prepare_mock_editwin_for_count_lines(self, editwin, linewidth, tabwidth): - """prepare a mock EditorWindow object so Squeezer.count_lines can run""" + """Prepare a mock EditorWindow object for Squeezer.count_lines.""" CHAR_WIDTH = 10 BORDER_WIDTH = 2 PADDING_WIDTH = 1 @@ -136,7 +136,7 @@ def measure(char): self.addCleanup(patcher.stop) def _test_count_lines_helper(self, linewidth, tabwidth): - """helper for test_count_lines""" + """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) @@ -149,14 +149,14 @@ def _test_count_lines_helper(self, linewidth, tabwidth): mock_count_lines.assert_called_with(text, linewidth, tabwidth) def test_init(self): - """test the creation of Squeezer instances""" + """Test the creation of Squeezer instances.""" editwin = self.make_mock_editor_window() squeezer = self.make_squeezer_instance(editwin) self.assertIs(squeezer.editwin, editwin) self.assertEqual(squeezer.expandingbuttons, []) def test_write_no_tags(self): - """test Squeezer's overriding of the EditorWindow's write() method""" + """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) @@ -168,7 +168,7 @@ def test_write_no_tags(self): self.assertEqual(len(squeezer.expandingbuttons), 0) def test_write_not_stdout(self): - """test Squeezer's overriding of the EditorWindow's write() method""" + """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 @@ -182,7 +182,7 @@ def test_write_not_stdout(self): self.assertEqual(len(squeezer.expandingbuttons), 0) def test_write_stdout(self): - """test Squeezer's overriding of the EditorWindow's write() method""" + """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) @@ -208,7 +208,7 @@ def test_write_stdout(self): self.assertEqual(len(squeezer.expandingbuttons), 1) def test_auto_squeeze(self): - """test that the auto-squeezing creates an ExpandingButton properly""" + """Test that the auto-squeezing creates an ExpandingButton properly.""" root = get_test_tk_root(self) text_widget = Text(root) text_widget.mark_set("iomark", "1.0") @@ -224,7 +224,7 @@ def test_auto_squeeze(self): self.assertEqual(len(squeezer.expandingbuttons), 1) def test_squeeze_current_text_event(self): - """test the squeeze_current_text event""" + """Test the squeeze_current_text event.""" root = get_test_tk_root(self) # squeezing text should work for both stdout and stderr @@ -258,7 +258,7 @@ def test_squeeze_current_text_event(self): self.assertEqual(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""" + """Test that the event doesn't squeeze text without a relevant tag.""" root = get_test_tk_root(self) text_widget = Text(root) @@ -283,7 +283,7 @@ def test_squeeze_current_text_event_no_allowed_tags(self): self.assertEqual(len(squeezer.expandingbuttons), 0) def test_squeeze_text_before_existing_squeezed_text(self): - """test squeezing text before existing squeezed text""" + """Test squeezing text before existing squeezed text.""" root = get_test_tk_root(self) text_widget = Text(root) @@ -329,19 +329,19 @@ def get_GetOption_signature(cls, mock_call_obj): return cls._make_sig(*args, **kwargs) def test_reload(self): - """test the reload() class-method""" + """Test the reload() class-method.""" self.assertIsInstance(Squeezer.auto_squeeze_min_lines, int) idleConf.SetOption('main', 'PyShell', 'auto-squeeze-min-lines', '42') Squeezer.reload() self.assertEqual(Squeezer.auto_squeeze_min_lines, 42) -class TestExpandingButton(unittest.TestCase): - """tests for the ExpandingButton class""" +class ExpandingButtonTest(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""" + """Helper for tests: Create a mock Squeezer object.""" root = get_test_tk_root(self) squeezer = Mock() squeezer.editwin.text = Text(root) @@ -352,7 +352,7 @@ def make_mock_squeezer(self): @patch('idlelib.squeezer.Hovertip', autospec=Hovertip) def test_init(self, MockHovertip): - """Test the simplest creation of an ExpandingButton""" + """Test the simplest creation of an ExpandingButton.""" squeezer = self.make_mock_squeezer() text_widget = squeezer.editwin.text @@ -380,7 +380,7 @@ def test_init(self, MockHovertip): self.assertIn('right-click', tooltip_text.lower()) def test_expand(self): - """test the expand event""" + """Test the expand event.""" squeezer = self.make_mock_squeezer() expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer) @@ -411,7 +411,7 @@ def test_expand(self): squeezer.expandingbuttons.remove.assert_called_with(expandingbutton) def test_expand_dangerous_oupput(self): - """attempting to expand very long output asks user for confirmation""" + """Test that expanding very long output asks user for confirmation.""" squeezer = self.make_mock_squeezer() text = 'a' * 10**5 expandingbutton = ExpandingButton(text, 'TAGS', 50, squeezer) @@ -452,7 +452,7 @@ def test_expand_dangerous_oupput(self): self.assertEqual(expandingbutton.text.get('1.0', 'end-1c'), text) def test_copy(self): - """test the copy event""" + """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 @@ -472,7 +472,7 @@ def test_copy(self): expandingbutton.clipboard_append.assert_called_with('TEXT') def test_view(self): - """test the view event""" + """Test the view event.""" squeezer = self.make_mock_squeezer() expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer) expandingbutton.selection_own = Mock() @@ -489,7 +489,7 @@ def test_view(self): self.assertEqual(mock_view_text.call_args[0][2], 'TEXT') def test_rmenu(self): - """test the context menu""" + """Test the context menu.""" squeezer = self.make_mock_squeezer() expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer) with patch('tkinter.Menu') as mock_Menu: