diff --git a/Lib/idlelib/NEWS.txt b/Lib/idlelib/NEWS.txt index 815d8aa7bf64cb..65662d9d060970 100644 --- a/Lib/idlelib/NEWS.txt +++ b/Lib/idlelib/NEWS.txt @@ -3,6 +3,8 @@ Released on 2019-??-?? ====================================== +bpo-35689: Add docstrings and tests for colorizer. + bpo-35833: Revise IDLE doc for control codes sent to Shell. Add a code example block. diff --git a/Lib/idlelib/colorizer.py b/Lib/idlelib/colorizer.py index d4e592b6ebe077..57942e8a7ca292 100644 --- a/Lib/idlelib/colorizer.py +++ b/Lib/idlelib/colorizer.py @@ -53,6 +53,21 @@ def color_config(text): class ColorDelegator(Delegator): + """Delegator for syntax highlighting (text coloring). + + Class variables: + after_id: Identifier for scheduled after event. + allow_colorizing: Boolean toggle for applying colorizing. + colorizing: Boolean flag when colorizing is in process. + stop_colorizing: Boolean flag to end an active colorizing + process. + close_when_done: Widget to destroy after colorizing process + completes (doesn't seem to be used by IDLE). + + Instance variables: + delegate: Delegator below this one in the stack, meaning the + one this one delegates to. + """ def __init__(self): Delegator.__init__(self) @@ -61,6 +76,16 @@ def __init__(self): self.LoadTagDefs() def setdelegate(self, delegate): + """Set the delegate for this instance. + + A delegate is an instance of a Delegator class and each + delegate points to the next delegator in the stack. This + allows multiple delegators to be chained together for a + widget. The bottom delegate for a colorizer is a Text + widget. + + If there is a delegate, also start the colorizing process. + """ if self.delegate is not None: self.unbind("<>") Delegator.setdelegate(self, delegate) @@ -69,17 +94,18 @@ def setdelegate(self, delegate): self.bind("<>", self.toggle_colorize_event) self.notify_range("1.0", "end") else: - # No delegate - stop any colorizing + # No delegate - stop any colorizing. self.stop_colorizing = True self.allow_colorizing = False def config_colors(self): + "Configure text widget tags with colors from tagdefs." for tag, cnf in self.tagdefs.items(): - if cnf: - self.tag_configure(tag, **cnf) + self.tag_configure(tag, **cnf) self.tag_raise('sel') def LoadTagDefs(self): + "Create dictionary of tag names to text colors." theme = idleConf.CurrentTheme() self.tagdefs = { "COMMENT": idleConf.GetHighlight(theme, "comment"), @@ -97,20 +123,24 @@ def LoadTagDefs(self): if DEBUG: print('tagdefs',self.tagdefs) def insert(self, index, chars, tags=None): + "Insert chars into widget at index and mark for colorizing." index = self.index(index) self.delegate.insert(index, chars, tags) self.notify_range(index, index + "+%dc" % len(chars)) def delete(self, index1, index2=None): + "Delete chars between indexes and mark for colorizing." index1 = self.index(index1) self.delegate.delete(index1, index2) self.notify_range(index1) after_id = None allow_colorizing = True + stop_colorizing = False colorizing = False def notify_range(self, index1, index2=None): + "Mark text changes for processing and restart colorizing, if active." self.tag_add("TODO", index1, index2) if self.after_id: if DEBUG: print("colorizing already scheduled") @@ -121,8 +151,9 @@ def notify_range(self, index1, index2=None): if self.allow_colorizing: if DEBUG: print("schedule colorizing") self.after_id = self.after(1, self.recolorize) + return - close_when_done = None # Window to be closed when done colorizing + close_when_done = None # Window to be closed when done colorizing. def close(self, close_when_done=None): if self.after_id: @@ -138,7 +169,14 @@ def close(self, close_when_done=None): else: self.close_when_done = close_when_done - def toggle_colorize_event(self, event): + def toggle_colorize_event(self, event=None): + """Toggle colorizing on and off. + + When toggling off, if colorizing is scheduled or is in + process, it will be cancelled and/or stopped. + + When toggling on, colorizing will be scheduled. + """ if self.after_id: after_id = self.after_id self.after_id = None @@ -156,6 +194,17 @@ def toggle_colorize_event(self, event): return "break" def recolorize(self): + """Timer event (every 1ms) to colorize text. + + Colorizing is only attempted when the text widget exists, + when colorizing is toggled on, and when the colorizing + process is not already running. + + After colorizing is complete, some cleanup is done to + make sure that all the text has been colorized and to close + the window if the close event had been called while the + process was running. + """ self.after_id = None if not self.delegate: if DEBUG: print("no delegate") @@ -185,6 +234,7 @@ def recolorize(self): top.destroy() def recolorize_main(self): + "Evaluate text and apply colorizing tags." next = "1.0" while True: item = self.tag_nextrange("TODO", next) @@ -250,6 +300,7 @@ def recolorize_main(self): return def removecolors(self): + "Remove all colorizing tags." for tag in self.tagdefs: self.tag_remove(tag, "1.0", "end") @@ -273,7 +324,7 @@ def _color_delegator(parent): # htest # "'x', '''x''', \"x\", \"\"\"x\"\"\"\n" "r'x', u'x', R'x', U'x', f'x', F'x'\n" "fr'x', Fr'x', fR'x', FR'x', rf'x', rF'x', Rf'x', RF'x'\n" - "b'x',B'x', br'x',Br'x',bR'x',BR'x', rb'x'.rB'x',Rb'x',RB'x'\n" + "b'x',B'x', br'x',Br'x',bR'x',BR'x', rb'x', rB'x',Rb'x',RB'x'\n" "# Invalid combinations of legal characters should be half colored.\n" "ur'x', ru'x', uf'x', fu'x', UR'x', ufr'x', rfu'x', xf'x', fx'x'\n" ) diff --git a/Lib/idlelib/idle_test/test_colorizer.py b/Lib/idlelib/idle_test/test_colorizer.py index 1e74bed1f0c0f0..4ade5a149b48f7 100644 --- a/Lib/idlelib/idle_test/test_colorizer.py +++ b/Lib/idlelib/idle_test/test_colorizer.py @@ -1,36 +1,103 @@ -"Test colorizer, coverage 25%." +"Test colorizer, coverage 93%." from idlelib import colorizer from test.support import requires -from tkinter import Tk, Text import unittest +from unittest import mock + +from functools import partial +from tkinter import Tk, Text +from idlelib import config +from idlelib.percolator import Percolator + + +usercfg = colorizer.idleConf.userCfg +testcfg = { + 'main': config.IdleUserConfParser(''), + 'highlight': config.IdleUserConfParser(''), + 'keys': config.IdleUserConfParser(''), + 'extensions': config.IdleUserConfParser(''), +} + +source = ( + "if True: int ('1') # keyword, builtin, string, comment\n" + "elif False: print(0) # 'string' in comment\n" + "else: float(None) # if in comment\n" + "if iF + If + IF: 'keyword matching must respect case'\n" + "if'': x or'' # valid string-keyword no-space combinations\n" + "async def f(): await g()\n" + "'x', '''x''', \"x\", \"\"\"x\"\"\"\n" + ) + + +def setUpModule(): + colorizer.idleConf.userCfg = testcfg + + +def tearDownModule(): + colorizer.idleConf.userCfg = usercfg class FunctionTest(unittest.TestCase): def test_any(self): - self.assertTrue(colorizer.any('test', ('a', 'b'))) + self.assertEqual(colorizer.any('test', ('a', 'b', 'cd')), + '(?Pa|b|cd)') def test_make_pat(self): + # Tested in more detail by testing prog. self.assertTrue(colorizer.make_pat()) + def test_prog(self): + prog = colorizer.prog + eq = self.assertEqual + line = 'def f():\n print("hello")\n' + m = prog.search(line) + eq(m.groupdict()['KEYWORD'], 'def') + m = prog.search(line, m.end()) + eq(m.groupdict()['SYNC'], '\n') + m = prog.search(line, m.end()) + eq(m.groupdict()['BUILTIN'], 'print') + m = prog.search(line, m.end()) + eq(m.groupdict()['STRING'], '"hello"') + m = prog.search(line, m.end()) + eq(m.groupdict()['SYNC'], '\n') + + def test_idprog(self): + idprog = colorizer.idprog + m = idprog.match('nospace') + self.assertIsNone(m) + m = idprog.match(' space') + self.assertEqual(m.group(0), ' space') + class ColorConfigTest(unittest.TestCase): @classmethod def setUpClass(cls): requires('gui') - cls.root = Tk() - cls.text = Text(cls.root) + root = cls.root = Tk() + root.withdraw() + cls.text = Text(root) @classmethod def tearDownClass(cls): del cls.text + cls.root.update_idletasks() cls.root.destroy() del cls.root - def test_colorizer(self): - colorizer.color_config(self.text) + def test_color_config(self): + text = self.text + eq = self.assertEqual + colorizer.color_config(text) + # Uses IDLE Classic theme as default. + eq(text['background'], '#ffffff') + eq(text['foreground'], '#000000') + eq(text['selectbackground'], 'gray') + eq(text['selectforeground'], '#000000') + eq(text['insertbackground'], 'black') + eq(text['inactiveselectbackground'], 'gray') class ColorDelegatorTest(unittest.TestCase): @@ -38,15 +105,286 @@ class ColorDelegatorTest(unittest.TestCase): @classmethod def setUpClass(cls): requires('gui') - cls.root = Tk() + root = cls.root = Tk() + root.withdraw() + text = cls.text = Text(root) + cls.percolator = Percolator(text) + # Delegator stack = [Delagator(text)] @classmethod def tearDownClass(cls): + cls.percolator.redir.close() + del cls.percolator, cls.text + cls.root.update_idletasks() cls.root.destroy() del cls.root - def test_colorizer(self): - colorizer.ColorDelegator() + def setUp(self): + self.color = colorizer.ColorDelegator() + self.percolator.insertfilter(self.color) + # Calls color.setdelagate(Delagator(text)). + + def tearDown(self): + self.color.close() + self.percolator.removefilter(self.color) + self.text.delete('1.0', 'end') + self.color.resetcache() + del self.color + + def test_init(self): + color = self.color + self.assertIsInstance(color, colorizer.ColorDelegator) + # The following are class variables. + self.assertTrue(color.allow_colorizing) + self.assertFalse(color.colorizing) + + def test_setdelegate(self): + # Called in setUp. + color = self.color + self.assertIsInstance(color.delegate, colorizer.Delegator) + # It is too late to mock notify_range, so test side effect. + self.assertEqual(self.root.tk.call( + 'after', 'info', color.after_id)[1], 'timer') + + def test_LoadTagDefs(self): + highlight = partial(config.idleConf.GetHighlight, theme='IDLE Classic') + for tag, colors in self.color.tagdefs.items(): + with self.subTest(tag=tag): + self.assertIn('background', colors) + self.assertIn('foreground', colors) + if tag not in ('SYNC', 'TODO'): + self.assertEqual(colors, highlight(element=tag.lower())) + + def test_config_colors(self): + text = self.text + highlight = partial(config.idleConf.GetHighlight, theme='IDLE Classic') + for tag in self.color.tagdefs: + for plane in ('background', 'foreground'): + with self.subTest(tag=tag, plane=plane): + if tag in ('SYNC', 'TODO'): + self.assertEqual(text.tag_cget(tag, plane), '') + else: + self.assertEqual(text.tag_cget(tag, plane), + highlight(element=tag.lower())[plane]) + # 'sel' is marked as the highest priority. + self.assertEqual(text.tag_names()[-1], 'sel') + + @mock.patch.object(colorizer.ColorDelegator, 'notify_range') + def test_insert(self, mock_notify): + text = self.text + # Initial text. + text.insert('insert', 'foo') + self.assertEqual(text.get('1.0', 'end'), 'foo\n') + mock_notify.assert_called_with('1.0', '1.0+3c') + # Additional text. + text.insert('insert', 'barbaz') + self.assertEqual(text.get('1.0', 'end'), 'foobarbaz\n') + mock_notify.assert_called_with('1.3', '1.3+6c') + + @mock.patch.object(colorizer.ColorDelegator, 'notify_range') + def test_delete(self, mock_notify): + text = self.text + # Initialize text. + text.insert('insert', 'abcdefghi') + self.assertEqual(text.get('1.0', 'end'), 'abcdefghi\n') + # Delete single character. + text.delete('1.7') + self.assertEqual(text.get('1.0', 'end'), 'abcdefgi\n') + mock_notify.assert_called_with('1.7') + # Delete multiple characters. + text.delete('1.3', '1.6') + self.assertEqual(text.get('1.0', 'end'), 'abcgi\n') + mock_notify.assert_called_with('1.3') + + def test_notify_range(self): + text = self.text + color = self.color + eq = self.assertEqual + + # Colorizing already scheduled. + save_id = color.after_id + eq(self.root.tk.call('after', 'info', save_id)[1], 'timer') + self.assertFalse(color.colorizing) + self.assertFalse(color.stop_colorizing) + self.assertTrue(color.allow_colorizing) + + # Coloring scheduled and colorizing in progress. + color.colorizing = True + color.notify_range('1.0', 'end') + self.assertFalse(color.stop_colorizing) + eq(color.after_id, save_id) + + # No colorizing scheduled and colorizing in progress. + text.after_cancel(save_id) + color.after_id = None + color.notify_range('1.0', '1.0+3c') + self.assertTrue(color.stop_colorizing) + self.assertIsNotNone(color.after_id) + eq(self.root.tk.call('after', 'info', color.after_id)[1], 'timer') + # New event scheduled. + self.assertNotEqual(color.after_id, save_id) + + # No colorizing scheduled and colorizing off. + text.after_cancel(color.after_id) + color.after_id = None + color.allow_colorizing = False + color.notify_range('1.4', '1.4+10c') + # Nothing scheduled when colorizing is off. + self.assertIsNone(color.after_id) + + def test_toggle_colorize_event(self): + color = self.color + eq = self.assertEqual + + # Starts with colorizing allowed and scheduled. + self.assertFalse(color.colorizing) + self.assertFalse(color.stop_colorizing) + self.assertTrue(color.allow_colorizing) + eq(self.root.tk.call('after', 'info', color.after_id)[1], 'timer') + + # Toggle colorizing off. + color.toggle_colorize_event() + self.assertIsNone(color.after_id) + self.assertFalse(color.colorizing) + self.assertFalse(color.stop_colorizing) + self.assertFalse(color.allow_colorizing) + + # Toggle on while colorizing in progress (doesn't add timer). + color.colorizing = True + color.toggle_colorize_event() + self.assertIsNone(color.after_id) + self.assertTrue(color.colorizing) + self.assertFalse(color.stop_colorizing) + self.assertTrue(color.allow_colorizing) + + # Toggle off while colorizing in progress. + color.toggle_colorize_event() + self.assertIsNone(color.after_id) + self.assertTrue(color.colorizing) + self.assertTrue(color.stop_colorizing) + self.assertFalse(color.allow_colorizing) + + # Toggle on while colorizing not in progress. + color.colorizing = False + color.toggle_colorize_event() + eq(self.root.tk.call('after', 'info', color.after_id)[1], 'timer') + self.assertFalse(color.colorizing) + self.assertTrue(color.stop_colorizing) + self.assertTrue(color.allow_colorizing) + + @mock.patch.object(colorizer.ColorDelegator, 'recolorize_main') + def test_recolorize(self, mock_recmain): + text = self.text + color = self.color + eq = self.assertEqual + # Call recolorize manually and not scheduled. + text.after_cancel(color.after_id) + + # No delegate. + save_delegate = color.delegate + color.delegate = None + color.recolorize() + mock_recmain.assert_not_called() + color.delegate = save_delegate + + # Toggle off colorizing. + color.allow_colorizing = False + color.recolorize() + mock_recmain.assert_not_called() + color.allow_colorizing = True + + # Colorizing in progress. + color.colorizing = True + color.recolorize() + mock_recmain.assert_not_called() + color.colorizing = False + + # Colorizing is done, but not completed, so rescheduled. + color.recolorize() + self.assertFalse(color.stop_colorizing) + self.assertFalse(color.colorizing) + mock_recmain.assert_called() + eq(mock_recmain.call_count, 1) + # Rescheduled when TODO tag still exists. + eq(self.root.tk.call('after', 'info', color.after_id)[1], 'timer') + + # No changes to text, so no scheduling added. + text.tag_remove('TODO', '1.0', 'end') + color.recolorize() + self.assertFalse(color.stop_colorizing) + self.assertFalse(color.colorizing) + mock_recmain.assert_called() + eq(mock_recmain.call_count, 2) + self.assertIsNone(color.after_id) + + @mock.patch.object(colorizer.ColorDelegator, 'notify_range') + def test_recolorize_main(self, mock_notify): + text = self.text + color = self.color + eq = self.assertEqual + + text.insert('insert', source) + expected = (('1.0', ('KEYWORD',)), ('1.2', ()), ('1.3', ('KEYWORD',)), + ('1.7', ()), ('1.9', ('BUILTIN',)), ('1.14', ('STRING',)), + ('1.19', ('COMMENT',)), + ('2.1', ('KEYWORD',)), ('2.18', ()), ('2.25', ('COMMENT',)), + ('3.6', ('BUILTIN',)), ('3.12', ('KEYWORD',)), ('3.21', ('COMMENT',)), + ('4.0', ('KEYWORD',)), ('4.3', ()), ('4.6', ()), + ('5.2', ('STRING',)), ('5.8', ('KEYWORD',)), ('5.10', ('STRING',)), + ('6.0', ('KEYWORD',)), ('6.10', ('DEFINITION',)), ('6.11', ()), + ('7.0', ('STRING',)), ('7.4', ()), ('7.5', ('STRING',)), + ('7.12', ()), ('7.14', ('STRING',)), + # SYNC at the end of every line. + ('1.55', ('SYNC',)), ('2.50', ('SYNC',)), ('3.34', ('SYNC',)), + ) + + # Nothing marked to do therefore no tags in text. + text.tag_remove('TODO', '1.0', 'end') + color.recolorize_main() + for tag in text.tag_names(): + with self.subTest(tag=tag): + eq(text.tag_ranges(tag), ()) + + # Source marked for processing. + text.tag_add('TODO', '1.0', 'end') + # Check some indexes. + color.recolorize_main() + for index, expected_tags in expected: + with self.subTest(index=index): + eq(text.tag_names(index), expected_tags) + + # Check for some tags for ranges. + eq(text.tag_nextrange('TODO', '1.0'), ()) + eq(text.tag_nextrange('KEYWORD', '1.0'), ('1.0', '1.2')) + eq(text.tag_nextrange('COMMENT', '2.0'), ('2.22', '2.43')) + eq(text.tag_nextrange('SYNC', '2.0'), ('2.43', '3.0')) + eq(text.tag_nextrange('STRING', '2.0'), ('4.17', '4.53')) + eq(text.tag_nextrange('STRING', '7.0'), ('7.0', '7.3')) + eq(text.tag_nextrange('STRING', '7.3'), ('7.5', '7.12')) + eq(text.tag_nextrange('STRING', '7.12'), ('7.14', '7.17')) + eq(text.tag_nextrange('STRING', '7.17'), ('7.19', '7.26')) + eq(text.tag_nextrange('SYNC', '7.0'), ('7.26', '9.0')) + + @mock.patch.object(colorizer.ColorDelegator, 'recolorize') + @mock.patch.object(colorizer.ColorDelegator, 'notify_range') + def test_removecolors(self, mock_notify, mock_recolorize): + text = self.text + color = self.color + text.insert('insert', source) + + color.recolorize_main() + # recolorize_main doesn't add these tags. + text.tag_add("ERROR", "1.0") + text.tag_add("TODO", "1.0") + text.tag_add("hit", "1.0") + for tag in color.tagdefs: + with self.subTest(tag=tag): + self.assertNotEqual(text.tag_ranges(tag), ()) + + color.removecolors() + for tag in color.tagdefs: + with self.subTest(tag=tag): + self.assertEqual(text.tag_ranges(tag), ()) if __name__ == '__main__': diff --git a/Misc/NEWS.d/next/IDLE/2019-01-08-17-51-44.bpo-35689.LlaqR8.rst b/Misc/NEWS.d/next/IDLE/2019-01-08-17-51-44.bpo-35689.LlaqR8.rst new file mode 100644 index 00000000000000..9628a6a13afc32 --- /dev/null +++ b/Misc/NEWS.d/next/IDLE/2019-01-08-17-51-44.bpo-35689.LlaqR8.rst @@ -0,0 +1 @@ +Add docstrings and unittests for colorizer.py.