[Python-checkins] bpo-35689: IDLE: Add docstrings and unittests for colorizer.py (GH-11472)

Miss Islington (bot) webhook-mailer at python.org
Tue Feb 19 00:30:58 EST 2019


https://github.com/python/cpython/commit/99e9c36b29a194d59ee4b0d8b82a72f5b0d409dd
commit: 99e9c36b29a194d59ee4b0d8b82a72f5b0d409dd
branch: 3.7
author: Miss Islington (bot) <31488909+miss-islington at users.noreply.github.com>
committer: GitHub <noreply at github.com>
date: 2019-02-18T21:30:53-08:00
summary:

bpo-35689: IDLE: Add docstrings and unittests for colorizer.py (GH-11472)

(cherry picked from commit ee0f927bd8dba805a04963dbec1ad49fe830b842)

Co-authored-by: Cheryl Sabella <cheryl.sabella at gmail.com>

files:
A Misc/NEWS.d/next/IDLE/2019-01-08-17-51-44.bpo-35689.LlaqR8.rst
M Lib/idlelib/NEWS.txt
M Lib/idlelib/colorizer.py
M Lib/idlelib/idle_test/test_colorizer.py

diff --git a/Lib/idlelib/NEWS.txt b/Lib/idlelib/NEWS.txt
index 815d8aa7bf64..65662d9d0609 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 d4e592b6ebe0..57942e8a7ca2 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("<<toggle-auto-coloring>>")
         Delegator.setdelegate(self, delegate)
@@ -69,17 +94,18 @@ def setdelegate(self, delegate):
             self.bind("<<toggle-auto-coloring>>", 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 1e74bed1f0c0..4ade5a149b48 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')),
+                         '(?P<test>a|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 000000000000..9628a6a13afc
--- /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.



More information about the Python-checkins mailing list