diff --git a/.gitignore b/.gitignore index c1e7fbc..f5c5142 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.pyc /dist/ +/build/ /*.egg-info diff --git a/tests/terminal_test.py b/tests/terminal_test.py index 0ad8c25..d03da1d 100755 --- a/tests/terminal_test.py +++ b/tests/terminal_test.py @@ -94,7 +94,7 @@ def testLineWrap(self): def testIssue1(self): self.assertEqual(10, len(terminal.StripAnsiText('boembabies' '\033[0m'))) - terminal.TerminalSize = lambda: (10, 10) + terminal.shutil.get_terminal_size = lambda: (10, 10) text1 = terminal.LineWrap('\033[32m' + 'boembabies, ' * 10 + 'boembabies' + '\033[0m', omit_sgr=True) text2 = ('\033[32m' + @@ -110,12 +110,20 @@ def __init__(self): # pylint: disable=C6409 def write(self, text): - self.output += text + # Ignore initial clear screen output. + if text != '\033[2J\033[H': + self.output += text # pylint: disable=C6409 def CountLines(self): return len(self.output.splitlines()) + def Show(self): + return self.output + + def Clear(self): + self.output = '' + def flush(self): pass @@ -124,39 +132,112 @@ class PagerTest(unittest.TestCase): def setUp(self): super(PagerTest, self).setUp() - sys.stdout = FakeTerminal() - self.get_ch_orig = terminal.Pager._GetCh - terminal.Pager._GetCh = lambda self: 'q' + self._output = FakeTerminal() + sys.stdout = self._output + self._getchar_orig = terminal._GetChar + # Quit the pager immediately after the first page. + terminal._GetChar = lambda: 'q' - self.p = terminal.Pager() + self._sample_text = '' + for i in range(10): + self._sample_text += str(i) + '\n' + + self.p = terminal.Pager(self._sample_text) + # Both the Prompt, and the ClearPrompt need to be accounted for. + self._prompt_lines = 2 def tearDown(self): super(PagerTest, self).tearDown() - terminal.Pager._GetCh = self.get_ch_orig + terminal._GetChar = self._getchar_orig sys.stdout = sys.__stdout__ - def testPager(self): - - self.p.Clear() - self.assertEqual('', self.p._text) - self.assertEqual(0, self.p._displayed) - self.assertEqual(1, self.p._lastscroll) + def testDisplay(self): + # Display a couple of rows (20%). + self.assertEqual(self.p._Display(0, 2), (2, 20.0, 10)) + self.assertEqual(self._output.Show(), '0\n1\n') + self._output.Clear() + self.assertEqual(self.p._Display(3, 2), (5, 50.0, 10)) + self.assertEqual(self._output.Show(), '3\n4\n') + self._output.Clear() + # Display past the end of the text. + self.assertEqual(self.p._Display(8, 3), (10, 100.0, 10)) + self.assertEqual(self._output.Show(), '8\n9\n') + self._output.Clear() + # Display before the start. Displays form the start. + self.assertEqual(self.p._Display(-1, 2), (2, 20.0, 10)) + self.assertEqual(self._output.Show(), '0\n1\n') + self._output.Clear() + # Display the rest of the text. + self.assertEqual(self.p._Display(7), (10, 100.0, 10)) + self.assertEqual(self._output.Show(), '7\n8\n9\n') + + def testPageAddsText(self): + extra_text = '10\n11\n' + self.p.Page(extra_text) + self.assertEqual(self.p._text, self._sample_text + extra_text) def testPage(self): - txt = '' - for i in range(100): - txt += '%d a random line of text here\n' % i - self.p._text = txt + self.p.SetLines(3) + self.p.Page() + self.assertEqual( + self._output.Show().splitlines()[:-self._prompt_lines], ['0', '1', '2']) + + def testPrompt(self): + self.p.SetLines(2) + # After paging once the progress will be 20%. + self.p.Page() + self._output.Clear() + self.assertEqual(self.p._Prompt(), terminal.AnsiText( + 'n: next line, Space: next page, b: prev page, q: quit.', + ['green'])) + # truncate width to 10 cols, prompt should be likewise truncated. + self.p._cols = 10 + self.assertEqual(self.p._Prompt(), + terminal.AnsiText('n: next li', ['green'])) + + def testPagerClear(self): + self.p.SetLines(2) + self.p.Page() + self.p.Reset() + # Clear output we aren't testing. + self._output.Clear() + # Paging after Reset resumes from the start. self.p.Page() - self.assertEqual(self.p._cli_lines+2, sys.stdout.CountLines()) + self.assertEqual(self._output.Show().splitlines()[:-self._prompt_lines], + ['0', '1']) - sys.stdout.output = '' - self.p = terminal.Pager() - self.p._text = '' - for _ in range(10): - self.p._text += 'a' * 100 + '\n' + def testPageAddPercent(self): + self.p.SetLines(2) + self.p.Page() + self.assertEqual(terminal.StripAnsiText( + self._output.Show().splitlines()[-self._prompt_lines]), + terminal.PROMPT_QUESTION + ' (20%)') + self.p.Page() + self.assertEqual(terminal.StripAnsiText( + self._output.Show().splitlines()[-self._prompt_lines]), + terminal.PROMPT_QUESTION + ' (40%)') + self.p.Page('10\n11\n') + # 50%, rather than 60%, as the total size increased from 10 to 12. + # But we don't show percent, as the source is streamed. + self.assertEqual(terminal.StripAnsiText( + self._output.Show().splitlines()[-self._prompt_lines]), + terminal.PROMPT_QUESTION) + self.p.Page('12\n13\n14\n15') + self.assertEqual(terminal.StripAnsiText( + self._output.Show().splitlines()[-self._prompt_lines]), + terminal.PROMPT_QUESTION) + self.p.Page() + self.assertEqual(terminal.StripAnsiText( + self._output.Show().splitlines()[-self._prompt_lines]), + terminal.PROMPT_QUESTION + ' (%d%%)' % (10 / 16 * 100)) + + def testBlankLines(self): + buffer = 'First line.\n\nThird line.\n' + self.p = terminal.Pager(buffer) + self.p.SetLines(4) self.p.Page() - self.assertEqual(20, sys.stdout.CountLines()) + self.assertEqual(self._output.Show().splitlines()[:-self._prompt_lines], + buffer.splitlines()) if __name__ == '__main__': diff --git a/textfsm/__init__.py b/textfsm/__init__.py index 69a6433..d5b99bb 100644 --- a/textfsm/__init__.py +++ b/textfsm/__init__.py @@ -10,4 +10,4 @@ """ from textfsm.parser import * -__version__ = '2.0.0' +__version__ = '2.1.0' diff --git a/textfsm/terminal.py b/textfsm/terminal.py index 32252a4..980f994 100755 --- a/textfsm/terminal.py +++ b/textfsm/terminal.py @@ -22,14 +22,7 @@ import shutil import sys import time - -try: - # Import fails on Windows machines. - import fcntl # pylint: disable=g-import-not-at-top - import termios # pylint: disable=g-import-not-at-top - import tty # pylint: disable=g-import-not-at-top -except (ImportError, ModuleNotFoundError): - pass +import typing # ANSI, ISO/IEC 6429 escape sequences, SGR (Select Graphic Rendition) subset. SGR = { @@ -92,13 +85,70 @@ 'grey': ['bg_white'], } - # Characters inserted at the start and end of ANSI strings # to provide hinting for readline and other clients. ANSI_START = '\001' ANSI_END = '\002' +# Arrow key sequences. +UP_ARROW = '\033[A' +DOWN_ARROW = '\033[B' + +# Clear the screen and move the cursor to the top left. +CLEAR_SCREEN = '\033[2J\033[H' + +# Navigational instructions for the user of the pager. +PROMPT_QUESTION = 'n: next line, Space: next page, b: prev page, q: quit.' + + +def _GetChar() -> str: + """Read a single character from the tty. + + Returns: + A string, the character read. + """ + # Default to 'q' to quit out of paging content. + return 'q' + +try: + # Import fails on Windows machines. + # pylint: disable=g-import-not-at-top + import termios + import tty + def _PosixGetChar() -> str: + """Read a single character from the tty.""" + try: + read_tty = open('/dev/tty') + except IOError: + # No TTY, revert to stdin + read_tty = sys.stdin + fd = read_tty.fileno() + old = termios.tcgetattr(fd) + try: + tty.setraw(fd) + ch = read_tty.read(1) + # Also support arrow key shortcuts (escape + 2 chars) + if ord(ch) == 27: + ch += read_tty.read(2) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + if '_tty' != sys.stdin: + read_tty.close() + return ch + _GetChar = _PosixGetChar +except (ImportError, ModuleNotFoundError): + # If we are on MS Windows then try using msvcrt library instead. + import msvcrt + def _MSGetChar() -> str: + ch = msvcrt.getch() # type: ignore + # Also support arrow key shortcuts (escape + 2 chars) + if ord(ch) == 27: + ch += msvcrt.getch() # type: ignore + return ch + _GetChar = _MSGetChar + +# Regular expression to match ANSI/SGR escape sequences. sgr_re = re.compile(r'(%s?\033\[\d+(?:;\d+)*m%s?)' % (ANSI_START, ANSI_END)) @@ -212,6 +262,11 @@ def _SplitWithSgr(text_line, width): text = str(text) text_multiline = [] for text_line in text.splitlines(): + if not text_line: + # Empty line, just add it. + text_multiline.append(text_line) + continue + # Is this a line that needs splitting? while (omit_sgr and (len(StripAnsiText(text_line)) > term_width)) or ( len(text_line) > term_width @@ -223,6 +278,7 @@ def _SplitWithSgr(text_line, width): else: (multiline_line, text_line) = _SplitWithSgr(text_line, term_width) text_multiline.append(multiline_line) + # If we have any text left over then add it. if text_line: text_multiline.append(text_line) return '\n'.join(text_multiline) @@ -251,7 +307,7 @@ class Pager(object): displayed, or the user has quit the pager. Currently supported keybindings are: - - one line down + n - one line down - one line down b - one page up - one line up @@ -260,7 +316,7 @@ class Pager(object): - one page down """ - def __init__(self, text=None, delay=None): + def __init__(self, text: str = '', delay: bool = False) -> None: """Constructor. Args: @@ -268,49 +324,87 @@ def __init__(self, text=None, delay=None): delay: A boolean, if True will cause a slight delay between line printing for more obvious scrolling. """ - self._text = text or '' - self._delay = delay - try: - self._tty = open('/dev/tty') - except IOError: - # No TTY, revert to stdin - self._tty = sys.stdin - self.SetLines(None) + self._text = text self.Reset() + self.SetLines() + # Add 0.005 sec delay between lines. + if delay: + self._delay = 0.005 + else: + self._delay = 0 - def __del__(self): - """Deconstructor, closes tty.""" - if getattr(self, '_tty', sys.stdin) is not sys.stdin: - self._tty.close() - - def Reset(self): + def Reset(self) -> None: """Reset the pager to the top of the text.""" - self._displayed = 0 - self._currentpagelines = 0 - self._lastscroll = 1 - self._lines_to_show = self._cli_lines + self.first_line = 0 - def SetLines(self, lines): - """Set number of screen lines. + def SetLines(self, num_lines: int = 0) -> typing.Tuple[int, int]: + """Set number of lines to display at a time. Args: - lines: An int, number of lines. If None, use terminal dimensions. + num_lines: An int, number of lines. If 0 use terminal dimensions. + Maximum number should be one less than full terminal height, + to allow for a user prompt. Raises: ValueError, TypeError: Not a valid integer representation. - """ - - (self._cli_cols, self._cli_lines) = shutil.get_terminal_size() - if lines: - self._cli_lines = int(lines) + Returns: + Tuple, the width and lines of the terminal. + """ - def Clear(self): + # Get the terminal size. + (cols, lines) = shutil.get_terminal_size() + # If we want paging by other than a whole window height. + # For a whole window height, we drop one line to leave room for prompting. + self._lines = int(num_lines) or lines - 1 + # Must be at least two rows, one row of output and one for the prompt. + self._lines = max(2, self._lines) + # Only number of rows is user configurable, we keep the terminal width. + self._cols = cols + return (self._cols, self._lines) + + def Clear(self) -> None: """Clear the text and reset the pager.""" self._text = '' self.Reset() - def Page(self, text=None, show_percent=None): + def _Display(self, start: int, length: int = 0 + ) -> typing.Tuple[int, float, int]: + """Display a range of lines from the text. + + Args: + start: An int, the first line to display. + length: An int, the number of lines to display. + Returns: + Tuple, the next line after, and a percentage for where that line is. + """ + + # Break text on newlines. But also break on line wrap. + text_list = LineWrap(self._text).splitlines() + total_length = len(text_list) + + # Bound start and end to be within the text. + start = max(0, start) + # If open-ended, trim to be whole of text. + if not length: + end = total_length + else: + end = min(start + length, total_length) + + self._WriteOut(CLEAR_SCREEN) + for i in range(start, end): + print(text_list[i]) + if self._delay: + time.sleep(self._delay) + + return (end, end / len(text_list) * 100, total_length) + + def _WriteOut(self, text: str) -> None: + """Write text to stdout.""" + sys.stdout.write(text) + sys.stdout.flush() + + def Page(self, more_text: str = '') -> None: """Page text. Continues to page through any text supplied in the constructor. Also, any @@ -319,115 +413,92 @@ def Page(self, text=None, show_percent=None): the user, or the user quits the pager. Args: - text: A string, extra text to be paged. - show_percent: A boolean, if True, indicate how much is displayed so far. - If None, this behaviour is 'text is None'. + more_text: A string, extra text to be appended. Returns: - A boolean. If True, more data can be displayed to the user. False - implies that the user has quit the pager. + A boolean: True: we have reached the end. False: the user has quit early. """ - if text is not None: - self._text += text - if show_percent is None: - show_percent = text is None - self._show_percent = show_percent + # With each page, more text can be added. + if more_text: + self._text += more_text + + only_quit = False + # Display a page of output. + (end, percent, total_length) = self._Display(self.first_line, self._lines) + # If less than a page to display, then 'quit' is only navigation option. + if total_length < self._lines: + only_quit = True - text = LineWrap(self._text).splitlines() + # While there is more text to be displayed. while True: - # Get a list of new lines to display. - self._newlines = text[ - self._displayed : self._displayed + self._lines_to_show - ] - for line in self._newlines: - sys.stdout.write(line + '\n') - if self._delay and self._lastscroll > 0: - time.sleep(0.005) - self._displayed += len(self._newlines) - self._currentpagelines += len(self._newlines) - if self._currentpagelines >= self._lines_to_show: - self._currentpagelines = 0 - wish = self._AskUser() - if wish == 'q': # Quit pager. - return False - elif wish == 'g': # Display till the end. - self._Scroll(len(text) - self._displayed + 1) - elif wish == '\r': # Enter, down a line. - self._Scroll(1) - elif wish == '\033[B': # Down arrow, down a line. - self._Scroll(1) - elif wish == '\033[A': # Up arrow, up a line. - self._Scroll(-1) - elif wish == 'b': # Up a page. - self._Scroll(0 - self._cli_lines) - else: # Next page. - self._Scroll() - if self._displayed >= len(text): + # If we are not reading streamed data then show % completion. + if not more_text: + wish = self._PromptUser(' (%d%%)' % percent) + else: + # If we are reading streamed data then show the prompt only. + wish = self._PromptUser() + + if wish == 'q': # Quit. break - return True + if only_quit: + # If we have less than a page of text, ignore navigational keys. + continue - def _Scroll(self, lines=None): - """Set attributes to scroll the buffer correctly. + if wish == 'g': # Display the remaining content. + (end, _, total_length) = self._Display(end) + self.first_line = end - self._lines + elif wish == 'n': + # Enter, down a line. + self.first_line += 1 + elif wish == DOWN_ARROW: + # Down a line. + self.first_line += 1 + elif wish == UP_ARROW: + # Up a line. + self.first_line -= 1 + elif wish == 'b': + # Up a page. + self.first_line -= self._lines + else: + # Down a page. + self.first_line += self._lines + + # Bound the first line to be within the text. + self.first_line = max(0, self.first_line) + self.first_line = min(total_length-self._lines, self.first_line) + # Display a page of output. + (end, percent, total_length) = self._Display( + self.first_line, self._lines) + + # Set first_line to the end, so when we next page we start from there. + self.first_line = end + + def _Prompt(self, suffix='') -> str: + question = PROMPT_QUESTION + suffix + # Truncate prompt to width of display. + question = question[:self._cols] + # Colorize the prompt. + return AnsiText(question, ['green']) + + def _ClearPrompt(self) -> str: + """Clear the prompt by over printing blank characters.""" + return '\r%s\r' % (' ' * self._cols) + + def _PromptUser(self, suffix='') -> str: + """Prompt the user for the next action. Args: - lines: An int, number of lines to scroll. If None, scrolls by the terminal - length. - """ - if lines is None: - lines = self._cli_lines - - if lines < 0: - self._displayed -= self._cli_lines - self._displayed += lines - if self._displayed < 0: - self._displayed = 0 - self._lines_to_show = self._cli_lines - else: - self._lines_to_show = lines - - self._lastscroll = lines - - def _AskUser(self): - """Prompt the user for the next action. + suffix: A string, to be appended to the prompt. Returns: A string, the character entered by the user. """ - if self._show_percent: - progress = int(self._displayed * 100 / (len(self._text.splitlines()))) - progress_text = ' (%d%%)' % progress - else: - progress_text = '' - question = AnsiText( - 'Enter: next line, Space: next page, b: prev page, q: quit.%s' - % progress_text, - ['green'], - ) - sys.stdout.write(question) - sys.stdout.flush() - ch = self._GetCh() - sys.stdout.write('\r%s\r' % (' ' * len(question))) - sys.stdout.flush() - return ch - - def _GetCh(self): - """Read a single character from the user. - Returns: - A string, the character read. - """ - fd = self._tty.fileno() - old = termios.tcgetattr(fd) - try: - tty.setraw(fd) - ch = self._tty.read(1) - # Also support arrow key shortcuts (escape + 2 chars) - if ord(ch) == 27: - ch += self._tty.read(2) - finally: - termios.tcsetattr(fd, termios.TCSADRAIN, old) + self._WriteOut(self._Prompt(suffix)) + ch = _GetChar() + self._WriteOut(self._ClearPrompt()) return ch @@ -449,16 +520,15 @@ def main(argv=None): print(help_msg) return 0 - isdelay = False + is_delay = False for opt, _ in opts: # Prints the size of the terminal and returns. - # Mutually exclusive to the paging of text and overrides that behaviour. + # Mutually exclusive to the paging of text and overrides that behavior. if opt in ('-s', '--size'): - print( - 'Width: %d, Length: %d' % shutil.get_terminal_size()) + print('Width: %d, Length: %d' % shutil.get_terminal_size()) return 0 elif opt in ('-d', '--delay'): - isdelay = True + is_delay = True else: raise UsageError('Invalid arguments.') @@ -469,7 +539,7 @@ def main(argv=None): fd = f.read() else: fd = sys.stdin.read() - Pager(fd, delay=isdelay).Page() + Pager(fd, delay=is_delay).Page() if __name__ == '__main__':