bpo-32831: IDLE: Add docstrings and tests for codecontext (GH-5638)
(cherry picked from commit 654038d896
)
Co-authored-by: Cheryl Sabella <cheryl.sabella@gmail.com>
This commit is contained in:
parent
436972e295
commit
0efa1353b7
|
@ -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)
|
||||
|
|
|
@ -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)
|
|
@ -0,0 +1 @@
|
|||
Add docstrings and tests for codecontext.
|
Loading…
Reference in New Issue