summaryrefslogtreecommitdiffstats
path: root/Lib
diff options
context:
space:
mode:
authorCheryl Sabella <cheryl.sabella@gmail.com>2018-05-19 19:34:03 (GMT)
committerTerry Jan Reedy <tjreedy@udel.edu>2018-05-19 19:34:03 (GMT)
commit654038d896d78a8373b60184f335acd516215acd (patch)
tree8e65f193192c1dbb71fd3f6a27ad92edb04aea77 /Lib
parentcf8abcbe0310ab4b3eb8b66ae795878b9df1a8ac (diff)
downloadcpython-654038d896d78a8373b60184f335acd516215acd.zip
cpython-654038d896d78a8373b60184f335acd516215acd.tar.gz
cpython-654038d896d78a8373b60184f335acd516215acd.tar.bz2
bpo-32831: IDLE: Add docstrings and tests for codecontext (GH-5638)
Diffstat (limited to 'Lib')
-rw-r--r--Lib/idlelib/codecontext.py63
-rw-r--r--Lib/idlelib/idle_test/test_codecontext.py347
2 files changed, 397 insertions, 13 deletions
diff --git a/Lib/idlelib/codecontext.py b/Lib/idlelib/codecontext.py
index 2bfb2e9..efd163e 100644
--- a/Lib/idlelib/codecontext.py
+++ b/Lib/idlelib/codecontext.py
@@ -22,32 +22,49 @@ BLOCKOPENERS = {"class", "def", "elif", "else", "except", "finally", "for",
UPDATEINTERVAL = 100 # millisec
FONTUPDATEINTERVAL = 1000 # millisec
+
def getspacesfirstword(s, c=re.compile(r"^(\s*)(\w*)")):
+ "Extract the beginning whitespace and first word from s."
return c.match(s).groups()
class CodeContext:
+ "Display block context above the edit window."
+
bgcolor = "LightGray"
fgcolor = "Black"
def __init__(self, editwin):
+ """Initialize settings for context block.
+
+ editwin is the Editor window for the context block.
+ self.text is the editor window text widget.
+ self.textfont is the editor window font.
+
+ self.label displays the code context text above the editor text.
+ Initially None it is toggled via <<toggle-code-context>>.
+ self.topvisible is the number of the top text line displayed.
+ self.info is a list of (line number, indent level, line text,
+ block keyword) tuples for the block structure above topvisible.
+ s self.info[0] is initialized a 'dummy' line which
+ # starts the toplevel 'block' of the module.
+
+ self.t1 and self.t2 are two timer events on the editor text widget to
+ monitor for changes to the context text or editor font.
+ """
self.editwin = editwin
self.text = editwin.text
self.textfont = self.text["font"]
self.label = None
- # self.info is a list of (line number, indent level, line text, block
- # keyword) tuples providing the block structure associated with
- # self.topvisible (the linenumber of the line displayed at the top of
- # the edit window). self.info[0] is initialized as a 'dummy' line which
- # starts the toplevel 'block' of the module.
- self.info = [(0, -1, "", False)]
self.topvisible = 1
+ self.info = [(0, -1, "", False)]
# Start two update cycles, one for context lines, one for font changes.
self.t1 = self.text.after(UPDATEINTERVAL, self.timer_event)
self.t2 = self.text.after(FONTUPDATEINTERVAL, self.font_timer_event)
@classmethod
def reload(cls):
+ "Load class variables from config."
cls.context_depth = idleConf.GetOption("extensions", "CodeContext",
"numlines", type="int", default=3)
## cls.bgcolor = idleConf.GetOption("extensions", "CodeContext",
@@ -56,6 +73,7 @@ class CodeContext:
## "fgcolor", type="str", default="Black")
def __del__(self):
+ "Cancel scheduled events."
try:
self.text.after_cancel(self.t1)
self.text.after_cancel(self.t2)
@@ -63,6 +81,12 @@ class CodeContext:
pass
def toggle_code_context_event(self, event=None):
+ """Toggle code context display.
+
+ If self.label doesn't exist, create it to match the size of the editor
+ window text (toggle on). If it does exist, destroy it (toggle off).
+ Return 'break' to complete the processing of the binding.
+ """
if not self.label:
# Calculate the border width and horizontal padding required to
# align the context with the text in the main Text widget.
@@ -95,11 +119,10 @@ class CodeContext:
return "break"
def get_line_info(self, linenum):
- """Get the line indent value, text, and any block start keyword
+ """Return tuple of (line indent value, text, and block start keyword).
If the line does not start a block, the keyword value is False.
The indentation of empty lines (or comment lines) is INFINITY.
-
"""
text = self.text.get("%d.0" % linenum, "%d.end" % linenum)
spaces, firstword = getspacesfirstword(text)
@@ -111,11 +134,13 @@ class CodeContext:
return indent, text, opener
def get_context(self, new_topvisible, stopline=1, stopindent=0):
- """Get context lines, starting at new_topvisible and working backwards.
-
- Stop when stopline or stopindent is reached. Return a tuple of context
- data and the indent level at the top of the region inspected.
+ """Return a list of block line tuples and the 'last' indent.
+ The tuple fields are (linenum, indent, text, opener).
+ The list represents header lines from new_topvisible back to
+ stopline with successively shorter indents > stopindent.
+ The list is returned ordered by line number.
+ Last indent returned is the smallest indent observed.
"""
assert stopline > 0
lines = []
@@ -140,6 +165,11 @@ class CodeContext:
def update_code_context(self):
"""Update context information and lines visible in the context pane.
+ No update is done if the text hasn't been scrolled. If the text
+ was scrolled, the lines that should be shown in the context will
+ be retrieved and the label widget will be updated with the code,
+ padded with blank lines so that the code appears on the bottom of
+ the context label.
"""
new_topvisible = int(self.text.index("@0,0").split('.')[0])
if self.topvisible == new_topvisible: # haven't scrolled
@@ -151,7 +181,7 @@ class CodeContext:
# between topvisible and new_topvisible:
while self.info[-1][1] >= lastindent:
del self.info[-1]
- elif self.topvisible > new_topvisible: # scroll up
+ else: # self.topvisible > new_topvisible: # scroll up
stopindent = self.info[-1][1] + 1
# retain only context info associated
# with lines above new_topvisible:
@@ -170,11 +200,13 @@ class CodeContext:
self.label["text"] = '\n'.join(context_strings)
def timer_event(self):
+ "Event on editor text widget triggered every UPDATEINTERVAL ms."
if self.label:
self.update_code_context()
self.t1 = self.text.after(UPDATEINTERVAL, self.timer_event)
def font_timer_event(self):
+ "Event on editor text widget triggered every FONTUPDATEINTERVAL ms."
newtextfont = self.text["font"]
if self.label and newtextfont != self.textfont:
self.textfont = newtextfont
@@ -183,3 +215,8 @@ class CodeContext:
CodeContext.reload()
+
+
+if __name__ == "__main__": # pragma: no cover
+ import unittest
+ unittest.main('idlelib.idle_test.test_codecontext', verbosity=2, exit=False)
diff --git a/Lib/idlelib/idle_test/test_codecontext.py b/Lib/idlelib/idle_test/test_codecontext.py
new file mode 100644
index 0000000..448094e
--- /dev/null
+++ b/Lib/idlelib/idle_test/test_codecontext.py
@@ -0,0 +1,347 @@
+"""Test idlelib.codecontext.
+
+Coverage: 100%
+"""
+
+import re
+
+import unittest
+from unittest import mock
+from test.support import requires
+from tkinter import Tk, Frame, Text, TclError
+
+import idlelib.codecontext as codecontext
+from idlelib import config
+
+
+usercfg = codecontext.idleConf.userCfg
+testcfg = {
+ 'main': config.IdleUserConfParser(''),
+ 'highlight': config.IdleUserConfParser(''),
+ 'keys': config.IdleUserConfParser(''),
+ 'extensions': config.IdleUserConfParser(''),
+}
+code_sample = """\
+
+class C1():
+ # Class comment.
+ def __init__(self, a, b):
+ self.a = a
+ self.b = b
+ def compare(self):
+ if a > b:
+ return a
+ elif a < b:
+ return b
+ else:
+ return None
+"""
+
+
+class DummyEditwin:
+ def __init__(self, root, frame, text):
+ self.root = root
+ self.top = root
+ self.text_frame = frame
+ self.text = text
+
+
+class CodeContextTest(unittest.TestCase):
+
+ @classmethod
+ def setUpClass(cls):
+ requires('gui')
+ root = cls.root = Tk()
+ root.withdraw()
+ frame = cls.frame = Frame(root)
+ text = cls.text = Text(frame)
+ text.insert('1.0', code_sample)
+ # Need to pack for creation of code context label widget.
+ frame.pack(side='left', fill='both', expand=1)
+ text.pack(side='top', fill='both', expand=1)
+ cls.editor = DummyEditwin(root, frame, text)
+ codecontext.idleConf.userCfg = testcfg
+
+ @classmethod
+ def tearDownClass(cls):
+ codecontext.idleConf.userCfg = usercfg
+ cls.editor.text.delete('1.0', 'end')
+ del cls.editor, cls.frame, cls.text
+ cls.root.update_idletasks()
+ cls.root.destroy()
+ del cls.root
+
+ def setUp(self):
+ self.cc = codecontext.CodeContext(self.editor)
+
+ def tearDown(self):
+ if self.cc.label:
+ self.cc.label.destroy()
+ # Explicitly call __del__ to remove scheduled scripts.
+ self.cc.__del__()
+ del self.cc.label, self.cc
+
+ def test_init(self):
+ eq = self.assertEqual
+ ed = self.editor
+ cc = self.cc
+
+ eq(cc.editwin, ed)
+ eq(cc.text, ed.text)
+ eq(cc.textfont, ed.text['font'])
+ self.assertIsNone(cc.label)
+ eq(cc.info, [(0, -1, '', False)])
+ eq(cc.topvisible, 1)
+ eq(self.root.tk.call('after', 'info', self.cc.t1)[1], 'timer')
+ eq(self.root.tk.call('after', 'info', self.cc.t2)[1], 'timer')
+
+ def test_del(self):
+ self.root.tk.call('after', 'info', self.cc.t1)
+ self.root.tk.call('after', 'info', self.cc.t2)
+ self.cc.__del__()
+ with self.assertRaises(TclError) as msg:
+ self.root.tk.call('after', 'info', self.cc.t1)
+ self.assertIn("doesn't exist", msg)
+ with self.assertRaises(TclError) as msg:
+ self.root.tk.call('after', 'info', self.cc.t2)
+ self.assertIn("doesn't exist", msg)
+ # For coverage on the except. Have to delete because the
+ # above Tcl error is caught by after_cancel.
+ del self.cc.t1, self.cc.t2
+ self.cc.__del__()
+
+ def test_reload(self):
+ codecontext.CodeContext.reload()
+ self.assertEqual(self.cc.context_depth, 3)
+
+ def test_toggle_code_context_event(self):
+ eq = self.assertEqual
+ cc = self.cc
+ toggle = cc.toggle_code_context_event
+
+ # Make sure code context is off.
+ if cc.label:
+ toggle()
+
+ # Toggle on.
+ eq(toggle(), 'break')
+ self.assertIsNotNone(cc.label)
+ eq(cc.label['font'], cc.textfont)
+ eq(cc.label['fg'], cc.fgcolor)
+ eq(cc.label['bg'], cc.bgcolor)
+ eq(cc.label['text'], '\n' * 2)
+
+ # Toggle off.
+ eq(toggle(), 'break')
+ self.assertIsNone(cc.label)
+
+ def test_get_line_info(self):
+ eq = self.assertEqual
+ gli = self.cc.get_line_info
+
+ # Line 1 is not a BLOCKOPENER.
+ eq(gli(1), (codecontext.INFINITY, '', False))
+ # Line 2 is a BLOCKOPENER without an indent.
+ eq(gli(2), (0, 'class C1():', 'class'))
+ # Line 3 is not a BLOCKOPENER and does not return the indent level.
+ eq(gli(3), (codecontext.INFINITY, ' # Class comment.', False))
+ # Line 4 is a BLOCKOPENER and is indented.
+ eq(gli(4), (4, ' def __init__(self, a, b):', 'def'))
+ # Line 8 is a different BLOCKOPENER and is indented.
+ eq(gli(8), (8, ' if a > b:', 'if'))
+
+ def test_get_context(self):
+ eq = self.assertEqual
+ gc = self.cc.get_context
+
+ # stopline must be greater than 0.
+ with self.assertRaises(AssertionError):
+ gc(1, stopline=0)
+
+ eq(gc(3), ([(2, 0, 'class C1():', 'class')], 0))
+
+ # Don't return comment.
+ eq(gc(4), ([(2, 0, 'class C1():', 'class')], 0))
+
+ # Two indentation levels and no comment.
+ eq(gc(5), ([(2, 0, 'class C1():', 'class'),
+ (4, 4, ' def __init__(self, a, b):', 'def')], 0))
+
+ # Only one 'def' is returned, not both at the same indent level.
+ eq(gc(10), ([(2, 0, 'class C1():', 'class'),
+ (7, 4, ' def compare(self):', 'def'),
+ (8, 8, ' if a > b:', 'if')], 0))
+
+ # With 'elif', also show the 'if' even though it's at the same level.
+ eq(gc(11), ([(2, 0, 'class C1():', 'class'),
+ (7, 4, ' def compare(self):', 'def'),
+ (8, 8, ' if a > b:', 'if'),
+ (10, 8, ' elif a < b:', 'elif')], 0))
+
+ # Set stop_line to not go back to first line in source code.
+ # Return includes stop_line.
+ eq(gc(11, stopline=2), ([(2, 0, 'class C1():', 'class'),
+ (7, 4, ' def compare(self):', 'def'),
+ (8, 8, ' if a > b:', 'if'),
+ (10, 8, ' elif a < b:', 'elif')], 0))
+ eq(gc(11, stopline=3), ([(7, 4, ' def compare(self):', 'def'),
+ (8, 8, ' if a > b:', 'if'),
+ (10, 8, ' elif a < b:', 'elif')], 4))
+ eq(gc(11, stopline=8), ([(8, 8, ' if a > b:', 'if'),
+ (10, 8, ' elif a < b:', 'elif')], 8))
+
+ # Set stop_indent to test indent level to stop at.
+ eq(gc(11, stopindent=4), ([(7, 4, ' def compare(self):', 'def'),
+ (8, 8, ' if a > b:', 'if'),
+ (10, 8, ' elif a < b:', 'elif')], 4))
+ # Check that the 'if' is included.
+ eq(gc(11, stopindent=8), ([(8, 8, ' if a > b:', 'if'),
+ (10, 8, ' elif a < b:', 'elif')], 8))
+
+ def test_update_code_context(self):
+ eq = self.assertEqual
+ cc = self.cc
+ # Ensure code context is active.
+ if not cc.label:
+ cc.toggle_code_context_event()
+
+ # Invoke update_code_context without scrolling - nothing happens.
+ self.assertIsNone(cc.update_code_context())
+ eq(cc.info, [(0, -1, '', False)])
+ eq(cc.topvisible, 1)
+
+ # Scroll down to line 2.
+ cc.text.yview(2)
+ cc.update_code_context()
+ eq(cc.info, [(0, -1, '', False), (2, 0, 'class C1():', 'class')])
+ eq(cc.topvisible, 3)
+ # context_depth is 3 so it pads with blank lines.
+ eq(cc.label['text'], '\n'
+ '\n'
+ 'class C1():')
+
+ # Scroll down to line 3. Since it's a comment, nothing changes.
+ cc.text.yview(3)
+ cc.update_code_context()
+ eq(cc.info, [(0, -1, '', False), (2, 0, 'class C1():', 'class')])
+ eq(cc.topvisible, 4)
+ eq(cc.label['text'], '\n'
+ '\n'
+ 'class C1():')
+
+ # Scroll down to line 4.
+ cc.text.yview(4)
+ cc.update_code_context()
+ eq(cc.info, [(0, -1, '', False),
+ (2, 0, 'class C1():', 'class'),
+ (4, 4, ' def __init__(self, a, b):', 'def')])
+ eq(cc.topvisible, 5)
+ eq(cc.label['text'], '\n'
+ 'class C1():\n'
+ ' def __init__(self, a, b):')
+
+ # Scroll down to line 11. Last 'def' is removed.
+ cc.text.yview(11)
+ cc.update_code_context()
+ eq(cc.info, [(0, -1, '', False),
+ (2, 0, 'class C1():', 'class'),
+ (7, 4, ' def compare(self):', 'def'),
+ (8, 8, ' if a > b:', 'if'),
+ (10, 8, ' elif a < b:', 'elif')])
+ eq(cc.topvisible, 12)
+ eq(cc.label['text'], ' def compare(self):\n'
+ ' if a > b:\n'
+ ' elif a < b:')
+
+ # No scroll. No update, even though context_depth changed.
+ cc.update_code_context()
+ cc.context_depth = 1
+ eq(cc.info, [(0, -1, '', False),
+ (2, 0, 'class C1():', 'class'),
+ (7, 4, ' def compare(self):', 'def'),
+ (8, 8, ' if a > b:', 'if'),
+ (10, 8, ' elif a < b:', 'elif')])
+ eq(cc.topvisible, 12)
+ eq(cc.label['text'], ' def compare(self):\n'
+ ' if a > b:\n'
+ ' elif a < b:')
+
+ # Scroll up.
+ cc.text.yview(5)
+ cc.update_code_context()
+ eq(cc.info, [(0, -1, '', False),
+ (2, 0, 'class C1():', 'class'),
+ (4, 4, ' def __init__(self, a, b):', 'def')])
+ eq(cc.topvisible, 6)
+ # context_depth is 1.
+ eq(cc.label['text'], ' def __init__(self, a, b):')
+
+ @mock.patch.object(codecontext.CodeContext, 'update_code_context')
+ def test_timer_event(self, mock_update):
+ # Ensure code context is not active.
+ if self.cc.label:
+ self.cc.toggle_code_context_event()
+ self.cc.timer_event()
+ mock_update.assert_not_called()
+
+ # Activate code context.
+ self.cc.toggle_code_context_event()
+ self.cc.timer_event()
+ mock_update.assert_called()
+
+ def test_font_timer_event(self):
+ eq = self.assertEqual
+ cc = self.cc
+ save_font = cc.text['font']
+ test_font = 'FakeFont'
+
+ # Ensure code context is not active.
+ if cc.label:
+ cc.toggle_code_context_event()
+
+ # Nothing updates on inactive code context.
+ cc.text['font'] = test_font
+ cc.font_timer_event()
+ eq(cc.textfont, save_font)
+
+ # Activate code context, but no change to font.
+ cc.toggle_code_context_event()
+ cc.text['font'] = save_font
+ cc.font_timer_event()
+ eq(cc.textfont, save_font)
+ eq(cc.label['font'], save_font)
+
+ # Active code context, change font.
+ cc.text['font'] = test_font
+ cc.font_timer_event()
+ eq(cc.textfont, test_font)
+ eq(cc.label['font'], test_font)
+
+ cc.text['font'] = save_font
+ cc.font_timer_event()
+
+
+class HelperFunctionText(unittest.TestCase):
+
+ def test_getspacesfirstword(self):
+ get = codecontext.getspacesfirstword
+ test_lines = (
+ (' first word', (' ', 'first')),
+ ('\tfirst word', ('\t', 'first')),
+ (' \u19D4\u19D2: ', (' ', '\u19D4\u19D2')),
+ ('no spaces', ('', 'no')),
+ ('', ('', '')),
+ ('# TEST COMMENT', ('', '')),
+ (' (continuation)', (' ', ''))
+ )
+ for line, expected_output in test_lines:
+ self.assertEqual(get(line), expected_output)
+
+ # Send the pattern in the call.
+ self.assertEqual(get(' (continuation)',
+ c=re.compile(r'^(\s*)([^\s]*)')),
+ (' ', '(continuation)'))
+
+
+if __name__ == '__main__':
+ unittest.main(verbosity=2)