From 4afb9e7c0c8092476ed98adccd2d389660827c25 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 3 Dec 2018 13:47:27 -0500 Subject: [PATCH 01/12] First version of adding an expanded option to history items --- cmd2/cmd2.py | 46 +++++++++++++++++---------- cmd2/parsing.py | 20 ++++++++++++ tests/conftest.py | 3 +- tests/test_cmd2.py | 51 ++++++++++++------------------ tests/transcripts/from_cmdloop.txt | 18 ++++------- 5 files changed, 77 insertions(+), 61 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 012b6f6b3..f30f0c12d 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -288,27 +288,36 @@ class EmptyStatement(Exception): class HistoryItem(str): - """Class used to represent an item in the History list. + """Class used to represent an item in the History list""" + listformat = ' {:>4} {}\n' + ex_listformat = ' Ex: {}\n' - Thin wrapper around str class which adds a custom format for printing. It - also keeps track of its index in the list as well as a lowercase - representation of itself for convenience/efficiency. + def __new__(cls, statement: Statement): + """Create a new instance of HistoryItem - """ - listformat = '-------------------------[{}]\n{}\n' + We must override __new__ because we are subclassing `str` which is + immutable and takes a different number of arguments as Statement. + """ + hi = super().__new__(cls, statement.raw) + hi.statement = statement + hi.idx = None + return hi - # noinspection PyUnusedLocal - def __init__(self, instr: str) -> None: - str.__init__(self) - self.lowercase = self.lower() - self.idx = None + @property + def expanded(self) -> str: + """Return the command as run which includes shortcuts and aliases resolved plus any changes made in hooks""" + return self.statement.expanded_command_line - def pr(self) -> str: + def pr(self, verbose: bool) -> str: """Represent a HistoryItem in a pretty fashion suitable for printing. :return: pretty print string version of a HistoryItem """ - return self.listformat.format(self.idx, str(self).rstrip()) + ret_str = self.listformat.format(self.idx, str(self).rstrip()) + if verbose and self != self.expanded: + ret_str += self.ex_listformat.format(self.expanded.rstrip()) + + return ret_str class Cmd(cmd.Cmd): @@ -2002,7 +2011,7 @@ def onecmd(self, statement: Union[Statement, str]) -> bool: if func: # Since we have a valid command store it in the history if statement.command not in self.exclude_from_history: - self.history.append(statement.raw) + self.history.append(statement) stop = func(statement) @@ -2065,7 +2074,7 @@ def default(self, statement: Statement) -> Optional[bool]: """ if self.default_to_shell: if 'shell' not in self.exclude_from_history: - self.history.append(statement.raw) + self.history.append(statement) return self.do_shell(statement.command_and_args) else: @@ -3163,6 +3172,9 @@ def load_ipy(app): setattr(history_parser_group.add_argument('-t', '--transcript', help='output commands and results to a transcript file'), ACTION_ARG_CHOICES, ('path_complete',)) + history_parser_group.add_argument('-v', '--verbose', action='store_true', + help='display history and include expanded commands if they' + ' differ from the typed command.') history_parser_group.add_argument('-c', '--clear', action="store_true", help='clear all history') history_arg_help = ("empty all history items\n" "a one history item by number\n" @@ -3246,7 +3258,7 @@ def do_history(self, args: argparse.Namespace) -> None: if args.script: self.poutput(hi) else: - self.poutput(hi.pr()) + self.poutput(hi.pr(args.verbose)) def _generate_transcript(self, history: List[HistoryItem], transcript_file: str) -> None: """Generate a transcript file from a given history of commands.""" @@ -3820,7 +3832,7 @@ def span(self, raw: str) -> List[HistoryItem]: rangePattern = re.compile(r'^\s*(?P[\d]+)?\s*-\s*(?P[\d]+)?\s*$') - def append(self, new: str) -> None: + def append(self, new: Statement) -> None: """Append a HistoryItem to end of the History list :param new: command line to convert to HistoryItem and add to the end of the History list diff --git a/cmd2/parsing.py b/cmd2/parsing.py index d5c67ae0b..f372b0d35 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -187,6 +187,26 @@ def command_and_args(self) -> str: rtn = '' return rtn + @property + def expanded_command_line(self) -> str: + """Contains command_and_args plus any ending terminator, suffix, and redirection chars""" + rtn = self.command_and_args + if self.terminator: + rtn += self.terminator + + if self.suffix: + rtn += ' ' + self.suffix + + if self.pipe_to: + rtn += ' | ' + self.pipe_to + + if self.output: + rtn += ' ' + self.output + if self.output_to: + rtn += ' ' + self.output_to + + return rtn + @property def argv(self) -> List[str]: """a list of arguments a la sys.argv. diff --git a/tests/conftest.py b/tests/conftest.py index ac6a18964..fdaf28097 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -51,7 +51,7 @@ """ # Help text for the history command -HELP_HISTORY = """Usage: history [-h] [-r | -e | -s | -o FILE | -t TRANSCRIPT | -c] [arg] +HELP_HISTORY = """Usage: history [-h] [-r | -e | -s | -o FILE | -t TRANSCRIPT | -v | -c] [arg] View, run, edit, save, or clear previously entered commands @@ -71,6 +71,7 @@ output commands to a script file -t, --transcript TRANSCRIPT output commands and results to a transcript file + -v, --verbose display history and include expanded commands if they differ from the typed command. -c, --clear clear all history """ diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 59c6bb602..ecca58cdd 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -303,8 +303,12 @@ def test_base_error(base_app): @pytest.fixture def hist(): + from cmd2.parsing import Statement from cmd2.cmd2 import History, HistoryItem - h = History([HistoryItem('first'), HistoryItem('second'), HistoryItem('third'), HistoryItem('fourth')]) + h = History([HistoryItem(Statement('', raw='first')), + HistoryItem(Statement('', raw='second')), + HistoryItem(Statement('', raw='third')), + HistoryItem(Statement('', raw='fourth'))]) return h def test_history_span(hist): @@ -334,24 +338,20 @@ def test_base_history(base_app): run_cmd(base_app, 'shortcuts') out = run_cmd(base_app, 'history') expected = normalize(""" --------------------------[1] -help --------------------------[2] -shortcuts + 1 help + 2 shortcuts """) assert out == expected out = run_cmd(base_app, 'history he') expected = normalize(""" --------------------------[1] -help + 1 help """) assert out == expected out = run_cmd(base_app, 'history sh') expected = normalize(""" --------------------------[2] -shortcuts + 2 shortcuts """) assert out == expected @@ -371,10 +371,8 @@ def test_history_with_string_argument(base_app): run_cmd(base_app, 'help history') out = run_cmd(base_app, 'history help') expected = normalize(""" --------------------------[1] -help --------------------------[3] -help history + 1 help + 3 help history """) assert out == expected @@ -384,8 +382,7 @@ def test_history_with_integer_argument(base_app): run_cmd(base_app, 'shortcuts') out = run_cmd(base_app, 'history 1') expected = normalize(""" --------------------------[1] -help + 1 help """) assert out == expected @@ -396,10 +393,8 @@ def test_history_with_integer_span(base_app): run_cmd(base_app, 'help history') out = run_cmd(base_app, 'history 1..2') expected = normalize(""" --------------------------[1] -help --------------------------[2] -shortcuts + 1 help + 2 shortcuts """) assert out == expected @@ -409,10 +404,8 @@ def test_history_with_span_start(base_app): run_cmd(base_app, 'help history') out = run_cmd(base_app, 'history 2:') expected = normalize(""" --------------------------[2] -shortcuts --------------------------[3] -help history + 2 shortcuts + 3 help history """) assert out == expected @@ -422,10 +415,8 @@ def test_history_with_span_end(base_app): run_cmd(base_app, 'help history') out = run_cmd(base_app, 'history :2') expected = normalize(""" --------------------------[1] -help --------------------------[2] -shortcuts + 1 help + 2 shortcuts """) assert out == expected @@ -435,8 +426,7 @@ def test_history_with_span_index_error(base_app): run_cmd(base_app, '!ls -hal :') out = run_cmd(base_app, 'history "hal :"') expected = normalize(""" --------------------------[3] -!ls -hal : + 3 !ls -hal : """) assert out == expected @@ -956,8 +946,7 @@ def test_exclude_from_history(base_app, monkeypatch): run_cmd(base_app, 'help') # And verify we have a history now ... out = run_cmd(base_app, 'history') - expected = normalize("""-------------------------[1] -help""") + expected = normalize(""" 1 help""") assert out == expected diff --git a/tests/transcripts/from_cmdloop.txt b/tests/transcripts/from_cmdloop.txt index 8c0dd007b..871b71f1d 100644 --- a/tests/transcripts/from_cmdloop.txt +++ b/tests/transcripts/from_cmdloop.txt @@ -35,18 +35,12 @@ OODNIGHT, GRACIEGAY OODNIGHT, GRACIEGAY OODNIGHT, GRACIEGAY (Cmd) history --------------------------[1] -help --------------------------[2] -help say --------------------------[3] -say goodnight, Gracie --------------------------[4] -say -ps --repeat=5 goodnight, Gracie --------------------------[5] -set maxrepeats 5 --------------------------[6] -say -ps --repeat=5 goodnight, Gracie + 1 help + 2 help say + 3 say goodnight, Gracie + 4 say -ps --repeat=5 goodnight, Gracie + 5 set maxrepeats 5 + 6 say -ps --repeat=5 goodnight, Gracie (Cmd) history -r 4 say -ps --repeat=5 goodnight, Gracie OODNIGHT, GRACIEGAY From 321a8c72a48d227011177bb91006ed20607a1e44 Mon Sep 17 00:00:00 2001 From: kotfu Date: Sat, 9 Feb 2019 13:43:41 -0700 Subject: [PATCH 02/12] Extract history classes and test into their own files --- cmd2/cmd2.py | 141 +--------------------------- cmd2/history.py | 151 ++++++++++++++++++++++++++++++ tests/test_cmd2.py | 190 -------------------------------------- tests/test_history.py | 209 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 361 insertions(+), 330 deletions(-) create mode 100644 cmd2/history.py create mode 100644 tests/test_history.py diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index ba2b6e8a9..69a6c2aa7 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -50,6 +50,7 @@ from .argparse_completer import AutoCompleter, ACArgumentParser, ACTION_ARG_CHOICES from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer from .parsing import StatementParser, Statement, Macro, MacroArg +from .history import History, HistoryItem # Set up readline from .rl_utils import rl_type, RlType, rl_get_point, rl_set_prompt, vt100_support, rl_make_safe_prompt @@ -287,39 +288,6 @@ class EmptyStatement(Exception): pass -class HistoryItem(str): - """Class used to represent an item in the History list""" - listformat = ' {:>4} {}\n' - ex_listformat = ' Ex: {}\n' - - def __new__(cls, statement: Statement): - """Create a new instance of HistoryItem - - We must override __new__ because we are subclassing `str` which is - immutable and takes a different number of arguments as Statement. - """ - hi = super().__new__(cls, statement.raw) - hi.statement = statement - hi.idx = None - return hi - - @property - def expanded(self) -> str: - """Return the command as run which includes shortcuts and aliases resolved plus any changes made in hooks""" - return self.statement.expanded_command_line - - def pr(self, verbose: bool) -> str: - """Represent a HistoryItem in a pretty fashion suitable for printing. - - :return: pretty print string version of a HistoryItem - """ - ret_str = self.listformat.format(self.idx, str(self).rstrip()) - if verbose and self != self.expanded: - ret_str += self.ex_listformat.format(self.expanded.rstrip()) - - return ret_str - - class Cmd(cmd.Cmd): """An easy but powerful framework for writing line-oriented command interpreters. @@ -3810,113 +3778,6 @@ def register_cmdfinalization_hook(self, func: Callable[[plugin.CommandFinalizati self._cmdfinalization_hooks.append(func) -class History(list): - """ A list of HistoryItems that knows how to respond to user requests. """ - - # noinspection PyMethodMayBeStatic - def _zero_based_index(self, onebased: int) -> int: - """Convert a one-based index to a zero-based index.""" - result = onebased - if result > 0: - result -= 1 - return result - - def _to_index(self, raw: str) -> Optional[int]: - if raw: - result = self._zero_based_index(int(raw)) - else: - result = None - return result - - spanpattern = re.compile(r'^\s*(?P-?\d+)?\s*(?P:|(\.{2,}))?\s*(?P-?\d+)?\s*$') - - def span(self, raw: str) -> List[HistoryItem]: - """Parses the input string search for a span pattern and if if found, returns a slice from the History list. - - :param raw: string potentially containing a span of the forms a..b, a:b, a:, ..b - :return: slice from the History list - """ - if raw.lower() in ('*', '-', 'all'): - raw = ':' - results = self.spanpattern.search(raw) - if not results: - raise IndexError - if not results.group('separator'): - return [self[self._to_index(results.group('start'))]] - start = self._to_index(results.group('start')) or 0 # Ensure start is not None - end = self._to_index(results.group('end')) - reverse = False - if end is not None: - if end < start: - (start, end) = (end, start) - reverse = True - end += 1 - result = self[start:end] - if reverse: - result.reverse() - return result - - rangePattern = re.compile(r'^\s*(?P[\d]+)?\s*-\s*(?P[\d]+)?\s*$') - - def append(self, new: Statement) -> None: - """Append a HistoryItem to end of the History list - - :param new: command line to convert to HistoryItem and add to the end of the History list - """ - new = HistoryItem(new) - list.append(self, new) - new.idx = len(self) - - def get(self, getme: Optional[Union[int, str]]=None) -> List[HistoryItem]: - """Get an item or items from the History list using 1-based indexing. - - :param getme: optional item(s) to get (either an integer index or string to search for) - :return: list of HistoryItems matching the retrieval criteria - """ - if not getme: - return self - try: - getme = int(getme) - if getme < 0: - return self[:(-1 * getme)] - else: - return [self[getme - 1]] - except IndexError: - return [] - except ValueError: - range_result = self.rangePattern.search(getme) - if range_result: - start = range_result.group('start') or None - end = range_result.group('start') or None - if start: - start = int(start) - 1 - if end: - end = int(end) - return self[start:end] - - getme = getme.strip() - - if getme.startswith(r'/') and getme.endswith(r'/'): - finder = re.compile(getme[1:-1], re.DOTALL | re.MULTILINE | re.IGNORECASE) - - def isin(hi): - """Listcomp filter function for doing a regular expression search of History. - - :param hi: HistoryItem - :return: bool - True if search matches - """ - return finder.search(hi) - else: - def isin(hi): - """Listcomp filter function for doing a case-insensitive string search of History. - - :param hi: HistoryItem - :return: bool - True if search matches - """ - return utils.norm_fold(getme) in utils.norm_fold(hi) - return [itm for itm in self if isin(itm)] - - class Statekeeper(object): """Class used to save and restore state during load and py commands as well as when redirecting output or pipes.""" def __init__(self, obj: Any, attribs: Iterable) -> None: diff --git a/cmd2/history.py b/cmd2/history.py new file mode 100644 index 000000000..0989b7db0 --- /dev/null +++ b/cmd2/history.py @@ -0,0 +1,151 @@ +# coding=utf-8 +""" +History management classes +""" + +import re + +from typing import List, Optional, Union + +from . import utils +from .parsing import Statement + + +class HistoryItem(str): + """Class used to represent one command in the History list""" + listformat = ' {:>4} {}\n' + ex_listformat = ' Ex: {}\n' + + def __new__(cls, statement: Statement): + """Create a new instance of HistoryItem + + We must override __new__ because we are subclassing `str` which is + immutable and takes a different number of arguments as Statement. + """ + hi = super().__new__(cls, statement.raw) + hi.statement = statement + hi.idx = None + return hi + + @property + def expanded(self) -> str: + """Return the command as run which includes shortcuts and aliases resolved plus any changes made in hooks""" + return self.statement.expanded_command_line + + def pr(self, verbose: bool) -> str: + """Represent a HistoryItem in a pretty fashion suitable for printing. + + :return: pretty print string version of a HistoryItem + """ + ret_str = self.listformat.format(self.idx, str(self).rstrip()) + if verbose and self != self.expanded: + ret_str += self.ex_listformat.format(self.expanded.rstrip()) + + return ret_str + + +class History(list): + """ A list of HistoryItems that knows how to respond to user requests. """ + + # noinspection PyMethodMayBeStatic + def _zero_based_index(self, onebased: int) -> int: + """Convert a one-based index to a zero-based index.""" + result = onebased + if result > 0: + result -= 1 + return result + + def _to_index(self, raw: str) -> Optional[int]: + if raw: + result = self._zero_based_index(int(raw)) + else: + result = None + return result + + spanpattern = re.compile(r'^\s*(?P-?\d+)?\s*(?P:|(\.{2,}))?\s*(?P-?\d+)?\s*$') + + def span(self, raw: str) -> List[HistoryItem]: + """Parses the input string search for a span pattern and if if found, returns a slice from the History list. + + :param raw: string potentially containing a span of the forms a..b, a:b, a:, ..b + :return: slice from the History list + """ + if raw.lower() in ('*', '-', 'all'): + raw = ':' + results = self.spanpattern.search(raw) + if not results: + raise IndexError + if not results.group('separator'): + return [self[self._to_index(results.group('start'))]] + start = self._to_index(results.group('start')) or 0 # Ensure start is not None + end = self._to_index(results.group('end')) + reverse = False + if end is not None: + if end < start: + (start, end) = (end, start) + reverse = True + end += 1 + result = self[start:end] + if reverse: + result.reverse() + return result + + rangePattern = re.compile(r'^\s*(?P[\d]+)?\s*-\s*(?P[\d]+)?\s*$') + + def append(self, new: Statement) -> None: + """Append a HistoryItem to end of the History list + + :param new: command line to convert to HistoryItem and add to the end of the History list + """ + new = HistoryItem(new) + list.append(self, new) + new.idx = len(self) + + def get(self, getme: Optional[Union[int, str]]=None) -> List[HistoryItem]: + """Get an item or items from the History list using 1-based indexing. + + :param getme: optional item(s) to get (either an integer index or string to search for) + :return: list of HistoryItems matching the retrieval criteria + """ + if not getme: + return self + try: + getme = int(getme) + if getme < 0: + return self[:(-1 * getme)] + else: + return [self[getme - 1]] + except IndexError: + return [] + except ValueError: + range_result = self.rangePattern.search(getme) + if range_result: + start = range_result.group('start') or None + end = range_result.group('start') or None + if start: + start = int(start) - 1 + if end: + end = int(end) + return self[start:end] + + getme = getme.strip() + + if getme.startswith(r'/') and getme.endswith(r'/'): + finder = re.compile(getme[1:-1], re.DOTALL | re.MULTILINE | re.IGNORECASE) + + def isin(hi): + """Listcomp filter function for doing a regular expression search of History. + + :param hi: HistoryItem + :return: bool - True if search matches + """ + return finder.search(hi) + else: + def isin(hi): + """Listcomp filter function for doing a case-insensitive string search of History. + + :param hi: HistoryItem + :return: bool - True if search matches + """ + return utils.norm_fold(getme) in utils.norm_fold(hi) + return [itm for itm in self if isin(itm)] diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 350991fae..bb415fe50 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -302,196 +302,6 @@ def test_base_error(base_app): assert "is not a recognized command" in out[0] -@pytest.fixture -def hist(): - from cmd2.parsing import Statement - from cmd2.cmd2 import History, HistoryItem - h = History([HistoryItem(Statement('', raw='first')), - HistoryItem(Statement('', raw='second')), - HistoryItem(Statement('', raw='third')), - HistoryItem(Statement('', raw='fourth'))]) - return h - -def test_history_span(hist): - h = hist - assert h == ['first', 'second', 'third', 'fourth'] - assert h.span('-2..') == ['third', 'fourth'] - assert h.span('2..3') == ['second', 'third'] # Inclusive of end - assert h.span('3') == ['third'] - assert h.span(':') == h - assert h.span('2..') == ['second', 'third', 'fourth'] - assert h.span('-1') == ['fourth'] - assert h.span('-2..-3') == ['third', 'second'] - assert h.span('*') == h - -def test_history_get(hist): - h = hist - assert h == ['first', 'second', 'third', 'fourth'] - assert h.get('') == h - assert h.get('-2') == h[:-2] - assert h.get('5') == [] - assert h.get('2-3') == ['second'] # Exclusive of end - assert h.get('ir') == ['first', 'third'] # Normal string search for all elements containing "ir" - assert h.get('/i.*d/') == ['third'] # Regex string search "i", then anything, then "d" - -def test_base_history(base_app): - run_cmd(base_app, 'help') - run_cmd(base_app, 'shortcuts') - out = run_cmd(base_app, 'history') - expected = normalize(""" - 1 help - 2 shortcuts -""") - assert out == expected - - out = run_cmd(base_app, 'history he') - expected = normalize(""" - 1 help -""") - assert out == expected - - out = run_cmd(base_app, 'history sh') - expected = normalize(""" - 2 shortcuts -""") - assert out == expected - -def test_history_script_format(base_app): - run_cmd(base_app, 'help') - run_cmd(base_app, 'shortcuts') - out = run_cmd(base_app, 'history -s') - expected = normalize(""" -help -shortcuts -""") - assert out == expected - -def test_history_with_string_argument(base_app): - run_cmd(base_app, 'help') - run_cmd(base_app, 'shortcuts') - run_cmd(base_app, 'help history') - out = run_cmd(base_app, 'history help') - expected = normalize(""" - 1 help - 3 help history -""") - assert out == expected - - -def test_history_with_integer_argument(base_app): - run_cmd(base_app, 'help') - run_cmd(base_app, 'shortcuts') - out = run_cmd(base_app, 'history 1') - expected = normalize(""" - 1 help -""") - assert out == expected - - -def test_history_with_integer_span(base_app): - run_cmd(base_app, 'help') - run_cmd(base_app, 'shortcuts') - run_cmd(base_app, 'help history') - out = run_cmd(base_app, 'history 1..2') - expected = normalize(""" - 1 help - 2 shortcuts -""") - assert out == expected - -def test_history_with_span_start(base_app): - run_cmd(base_app, 'help') - run_cmd(base_app, 'shortcuts') - run_cmd(base_app, 'help history') - out = run_cmd(base_app, 'history 2:') - expected = normalize(""" - 2 shortcuts - 3 help history -""") - assert out == expected - -def test_history_with_span_end(base_app): - run_cmd(base_app, 'help') - run_cmd(base_app, 'shortcuts') - run_cmd(base_app, 'help history') - out = run_cmd(base_app, 'history :2') - expected = normalize(""" - 1 help - 2 shortcuts -""") - assert out == expected - -def test_history_with_span_index_error(base_app): - run_cmd(base_app, 'help') - run_cmd(base_app, 'help history') - run_cmd(base_app, '!ls -hal :') - out = run_cmd(base_app, 'history "hal :"') - expected = normalize(""" - 3 !ls -hal : -""") - assert out == expected - -def test_history_output_file(base_app): - run_cmd(base_app, 'help') - run_cmd(base_app, 'shortcuts') - run_cmd(base_app, 'help history') - - fd, fname = tempfile.mkstemp(prefix='', suffix='.txt') - os.close(fd) - run_cmd(base_app, 'history -o "{}"'.format(fname)) - expected = normalize('\n'.join(['help', 'shortcuts', 'help history'])) - with open(fname) as f: - content = normalize(f.read()) - assert content == expected - -def test_history_edit(base_app, monkeypatch): - # Set a fake editor just to make sure we have one. We aren't really - # going to call it due to the mock - base_app.editor = 'fooedit' - - # Mock out the os.system call so we don't actually open an editor - m = mock.MagicMock(name='system') - monkeypatch.setattr("os.system", m) - - # Run help command just so we have a command in history - run_cmd(base_app, 'help') - run_cmd(base_app, 'history -e 1') - - # We have an editor, so should expect a system call - m.assert_called_once() - -def test_history_run_all_commands(base_app): - # make sure we refuse to run all commands as a default - run_cmd(base_app, 'shortcuts') - out = run_cmd(base_app, 'history -r') - # this should generate an error, but we don't currently have a way to - # capture stderr in these tests. So we assume that if we got nothing on - # standard out, that the error occurred because if the command executed - # then we should have a list of shortcuts in our output - assert out == [] - -def test_history_run_one_command(base_app): - expected = run_cmd(base_app, 'help') - output = run_cmd(base_app, 'history -r 1') - assert output == expected - -def test_history_clear(base_app): - # Add commands to history - run_cmd(base_app, 'help') - run_cmd(base_app, 'alias') - - # Make sure history has items - out = run_cmd(base_app, 'history') - assert out - - # Clear the history - run_cmd(base_app, 'history --clear') - - # Make sure history is empty - out = run_cmd(base_app, 'history') - assert out == [] - - def test_base_load(base_app, request): test_dir = os.path.dirname(request.module.__file__) filename = os.path.join(test_dir, 'script.txt') diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 000000000..0eddcc1f9 --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,209 @@ +# coding=utf-8 +# flake8: noqa E302 +""" +Test history functions of cmd2 +""" +import tempfile +import os + +import pytest + +# Python 3.5 had some regressions in the unitest.mock module, so use 3rd party mock if available +try: + import mock +except ImportError: + from unittest import mock + +import cmd2 +from cmd2 import clipboard +from cmd2 import utils +from .conftest import run_cmd, normalize + +@pytest.fixture +def hist(): + from cmd2.parsing import Statement + from cmd2.cmd2 import History, HistoryItem + h = History([HistoryItem(Statement('', raw='first')), + HistoryItem(Statement('', raw='second')), + HistoryItem(Statement('', raw='third')), + HistoryItem(Statement('', raw='fourth'))]) + return h + +def test_history_span(hist): + h = hist + assert h == ['first', 'second', 'third', 'fourth'] + assert h.span('-2..') == ['third', 'fourth'] + assert h.span('2..3') == ['second', 'third'] # Inclusive of end + assert h.span('3') == ['third'] + assert h.span(':') == h + assert h.span('2..') == ['second', 'third', 'fourth'] + assert h.span('-1') == ['fourth'] + assert h.span('-2..-3') == ['third', 'second'] + assert h.span('*') == h + +def test_history_get(hist): + h = hist + assert h == ['first', 'second', 'third', 'fourth'] + assert h.get('') == h + assert h.get('-2') == h[:-2] + assert h.get('5') == [] + assert h.get('2-3') == ['second'] # Exclusive of end + assert h.get('ir') == ['first', 'third'] # Normal string search for all elements containing "ir" + assert h.get('/i.*d/') == ['third'] # Regex string search "i", then anything, then "d" + +def test_base_history(base_app): + run_cmd(base_app, 'help') + run_cmd(base_app, 'shortcuts') + out = run_cmd(base_app, 'history') + expected = normalize(""" + 1 help + 2 shortcuts +""") + assert out == expected + + out = run_cmd(base_app, 'history he') + expected = normalize(""" + 1 help +""") + assert out == expected + + out = run_cmd(base_app, 'history sh') + expected = normalize(""" + 2 shortcuts +""") + assert out == expected + +def test_history_script_format(base_app): + run_cmd(base_app, 'help') + run_cmd(base_app, 'shortcuts') + out = run_cmd(base_app, 'history -s') + expected = normalize(""" +help +shortcuts +""") + assert out == expected + +def test_history_with_string_argument(base_app): + run_cmd(base_app, 'help') + run_cmd(base_app, 'shortcuts') + run_cmd(base_app, 'help history') + out = run_cmd(base_app, 'history help') + expected = normalize(""" + 1 help + 3 help history +""") + assert out == expected + + +def test_history_with_integer_argument(base_app): + run_cmd(base_app, 'help') + run_cmd(base_app, 'shortcuts') + out = run_cmd(base_app, 'history 1') + expected = normalize(""" + 1 help +""") + assert out == expected + + +def test_history_with_integer_span(base_app): + run_cmd(base_app, 'help') + run_cmd(base_app, 'shortcuts') + run_cmd(base_app, 'help history') + out = run_cmd(base_app, 'history 1..2') + expected = normalize(""" + 1 help + 2 shortcuts +""") + assert out == expected + +def test_history_with_span_start(base_app): + run_cmd(base_app, 'help') + run_cmd(base_app, 'shortcuts') + run_cmd(base_app, 'help history') + out = run_cmd(base_app, 'history 2:') + expected = normalize(""" + 2 shortcuts + 3 help history +""") + assert out == expected + +def test_history_with_span_end(base_app): + run_cmd(base_app, 'help') + run_cmd(base_app, 'shortcuts') + run_cmd(base_app, 'help history') + out = run_cmd(base_app, 'history :2') + expected = normalize(""" + 1 help + 2 shortcuts +""") + assert out == expected + +def test_history_with_span_index_error(base_app): + run_cmd(base_app, 'help') + run_cmd(base_app, 'help history') + run_cmd(base_app, '!ls -hal :') + out = run_cmd(base_app, 'history "hal :"') + expected = normalize(""" + 3 !ls -hal : +""") + assert out == expected + +def test_history_output_file(base_app): + run_cmd(base_app, 'help') + run_cmd(base_app, 'shortcuts') + run_cmd(base_app, 'help history') + + fd, fname = tempfile.mkstemp(prefix='', suffix='.txt') + os.close(fd) + run_cmd(base_app, 'history -o "{}"'.format(fname)) + expected = normalize('\n'.join(['help', 'shortcuts', 'help history'])) + with open(fname) as f: + content = normalize(f.read()) + assert content == expected + +def test_history_edit(base_app, monkeypatch): + # Set a fake editor just to make sure we have one. We aren't really + # going to call it due to the mock + base_app.editor = 'fooedit' + + # Mock out the os.system call so we don't actually open an editor + m = mock.MagicMock(name='system') + monkeypatch.setattr("os.system", m) + + # Run help command just so we have a command in history + run_cmd(base_app, 'help') + run_cmd(base_app, 'history -e 1') + + # We have an editor, so should expect a system call + m.assert_called_once() + +def test_history_run_all_commands(base_app): + # make sure we refuse to run all commands as a default + run_cmd(base_app, 'shortcuts') + out = run_cmd(base_app, 'history -r') + # this should generate an error, but we don't currently have a way to + # capture stderr in these tests. So we assume that if we got nothing on + # standard out, that the error occurred because if the command executed + # then we should have a list of shortcuts in our output + assert out == [] + +def test_history_run_one_command(base_app): + expected = run_cmd(base_app, 'help') + output = run_cmd(base_app, 'history -r 1') + assert output == expected + +def test_history_clear(base_app): + # Add commands to history + run_cmd(base_app, 'help') + run_cmd(base_app, 'alias') + + # Make sure history has items + out = run_cmd(base_app, 'history') + assert out + + # Clear the history + run_cmd(base_app, 'history --clear') + + # Make sure history is empty + out = run_cmd(base_app, 'history') + assert out == [] From 3911335e5533405dd7f65195fe1f20bf3ac08ef8 Mon Sep 17 00:00:00 2001 From: kotfu Date: Sat, 9 Feb 2019 18:39:17 -0700 Subject: [PATCH 03/12] Added -x option to history command for #545 --- cmd2/cmd2.py | 42 +++++++++++++++++++---------- cmd2/history.py | 27 ++++++++++++++----- tests/test_history.py | 61 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 108 insertions(+), 22 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 69a6c2aa7..1d9cbe856 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3155,21 +3155,25 @@ def load_ipy(app): load_ipy(bridge) history_parser = ACArgumentParser() - history_parser_group = history_parser.add_mutually_exclusive_group() - history_parser_group.add_argument('-r', '--run', action='store_true', help='run selected history items') - history_parser_group.add_argument('-e', '--edit', action='store_true', + history_action_group = history_parser.add_mutually_exclusive_group() + history_action_group.add_argument('-r', '--run', action='store_true', help='run selected history items') + history_action_group.add_argument('-e', '--edit', action='store_true', help='edit and then run selected history items') - history_parser_group.add_argument('-s', '--script', action='store_true', help='output commands in script format') - setattr(history_parser_group.add_argument('-o', '--output-file', metavar='FILE', - help='output commands to a script file'), + setattr(history_action_group.add_argument('-o', '--output-file', metavar='FILE', + help='output commands to a script file, implies -s'), ACTION_ARG_CHOICES, ('path_complete',)) - setattr(history_parser_group.add_argument('-t', '--transcript', - help='output commands and results to a transcript file'), + setattr(history_action_group.add_argument('-t', '--transcript', + help='output commands and results to a transcript file, implies -s'), ACTION_ARG_CHOICES, ('path_complete',)) - history_parser_group.add_argument('-v', '--verbose', action='store_true', + history_action_group.add_argument('-c', '--clear', action='store_true', help='clear all history') + + history_format_group = history_parser.add_argument_group(title='formatting') + history_format_group.add_argument('-s', '--script', action='store_true', help='output commands in script format, i.e. without command numbers') + history_format_group.add_argument('-x', '--expanded', action='store_true', help='output expanded commands instead of entered command') + history_format_group.add_argument('-v', '--verbose', action='store_true', help='display history and include expanded commands if they' ' differ from the typed command.') - history_parser_group.add_argument('-c', '--clear', action="store_true", help='clear all history') + history_arg_help = ("empty all history items\n" "a one history item by number\n" "a..b, a:b, a:, ..b items by indices (inclusive)\n" @@ -3181,6 +3185,19 @@ def load_ipy(app): def do_history(self, args: argparse.Namespace) -> None: """View, run, edit, save, or clear previously entered commands""" + # -v must be used alone with no other options + if args.verbose: + if args.clear or args.edit or args.output_file or args.run or args.transcript or args.expanded or args.script: + self.poutput("-v can not be used with any other options") + self.poutput(self.history_parser.format_usage()) + return + + # -s and -x can only be used if none of these options are present: [-c -r -e -o -t] + if (args.script or args.expanded) and (args.clear or args.edit or args.output_file or args.run or args.transcript): + self.poutput("-s and -x can not be used with -c, -r, -e, -o, or -t") + self.poutput(self.history_parser.format_usage()) + return + if args.clear: # Clear command and readline history self.history.clear() @@ -3249,10 +3266,7 @@ def do_history(self, args: argparse.Namespace) -> None: else: # Display the history items retrieved for hi in history: - if args.script: - self.poutput(hi) - else: - self.poutput(hi.pr(args.verbose)) + self.poutput(hi.pr(script=args.script, expanded=args.expanded, verbose=args.verbose)) def _generate_transcript(self, history: List[HistoryItem], transcript_file: str) -> None: """Generate a transcript file from a given history of commands.""" diff --git a/cmd2/history.py b/cmd2/history.py index 0989b7db0..ad4b23aa9 100644 --- a/cmd2/history.py +++ b/cmd2/history.py @@ -14,7 +14,7 @@ class HistoryItem(str): """Class used to represent one command in the History list""" listformat = ' {:>4} {}\n' - ex_listformat = ' Ex: {}\n' + ex_listformat = ' {:>4}x {}\n' def __new__(cls, statement: Statement): """Create a new instance of HistoryItem @@ -32,15 +32,30 @@ def expanded(self) -> str: """Return the command as run which includes shortcuts and aliases resolved plus any changes made in hooks""" return self.statement.expanded_command_line - def pr(self, verbose: bool) -> str: + def pr(self, script=False, expanded=False, verbose=False) -> str: """Represent a HistoryItem in a pretty fashion suitable for printing. + If you pass verbose=True, script and expanded will be ignored + :return: pretty print string version of a HistoryItem """ - ret_str = self.listformat.format(self.idx, str(self).rstrip()) - if verbose and self != self.expanded: - ret_str += self.ex_listformat.format(self.expanded.rstrip()) - + if verbose: + ret_str = self.listformat.format(self.idx, str(self).rstrip()) + if self != self.expanded: + ret_str += self.ex_listformat.format(self.idx, self.expanded.rstrip()) + else: + if script: + # display without entry numbers + if expanded: + ret_str = self.expanded.rstrip() + else: + ret_str = str(self) + else: + # display a numbered list + if expanded: + ret_str = self.listformat.format(self.idx, self.expanded.rstrip()) + else: + ret_str = self.listformat.format(self.idx, str(self).rstrip()) return ret_str diff --git a/tests/test_history.py b/tests/test_history.py index 0eddcc1f9..98d72512b 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -29,7 +29,7 @@ def hist(): HistoryItem(Statement('', raw='fourth'))]) return h -def test_history_span(hist): +def test_history_class_span(hist): h = hist assert h == ['first', 'second', 'third', 'fourth'] assert h.span('-2..') == ['third', 'fourth'] @@ -41,7 +41,7 @@ def test_history_span(hist): assert h.span('-2..-3') == ['third', 'second'] assert h.span('*') == h -def test_history_get(hist): +def test_history_class_get(hist): h = hist assert h == ['first', 'second', 'third', 'fourth'] assert h.get('') == h @@ -207,3 +207,60 @@ def test_history_clear(base_app): # Make sure history is empty out = run_cmd(base_app, 'history') assert out == [] + +def test_history_verbose_with_other_options(base_app): + # make sure -v shows a usage error if any other options are present + options_to_test = ['-r', '-e', '-o file', '-t file', '-c', '-x'] + for opt in options_to_test: + output = run_cmd(base_app, 'history -v ' + opt) + assert len(output) == 3 + assert output[1].startswith('Usage:') + +def test_history_verbose(base_app): + # validate function of -v option + run_cmd(base_app, 'alias create s shortcuts') + run_cmd(base_app, 's') + output = run_cmd(base_app, 'history -v') + assert len(output) == 3 + # TODO test for basic formatting once we figure it out + +def test_history_script_with_invalid_options(base_app): + # make sure -s shows a usage error if -c, -r, -e, -o, or -t are present + options_to_test = ['-r', '-e', '-o file', '-t file', '-c'] + for opt in options_to_test: + output = run_cmd(base_app, 'history -s ' + opt) + assert len(output) == 3 + assert output[1].startswith('Usage:') + +def test_history_script(base_app): + cmds = ['alias create s shortcuts', 's'] + for cmd in cmds: + run_cmd(base_app, cmd) + output = run_cmd(base_app, 'history -s') + assert output == cmds + +def test_history_expanded_with_invalid_options(base_app): + # make sure -x shows a usage error if -c, -r, -e, -o, or -t are present + options_to_test = ['-r', '-e', '-o file', '-t file', '-c'] + for opt in options_to_test: + output = run_cmd(base_app, 'history -x ' + opt) + assert len(output) == 3 + assert output[1].startswith('Usage:') + +def test_history_expanded(base_app): + # validate function of -x option + cmds = ['alias create s shortcuts', 's'] + for cmd in cmds: + run_cmd(base_app, cmd) + output = run_cmd(base_app, 'history -x') + expected = [' 1 alias create s shortcuts', ' 2 shortcuts'] + assert output == expected + +def test_history_script_expanded(base_app): + # validate function of -s -x options together + cmds = ['alias create s shortcuts', 's'] + for cmd in cmds: + run_cmd(base_app, cmd) + output = run_cmd(base_app, 'history -sx') + expected = ['alias create s shortcuts', 'shortcuts'] + assert output == expected From b7fc503fcff1b8454df7f33a375c83b045ed8725 Mon Sep 17 00:00:00 2001 From: kotfu Date: Sat, 9 Feb 2019 19:38:56 -0700 Subject: [PATCH 04/12] Move the rest of the history tests into test_history.py --- cmd2/cmd2.py | 2 +- tests/conftest.py | 17 +++--- tests/test_cmd2.py | 109 +-------------------------------------- tests/test_history.py | 117 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 129 insertions(+), 116 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 1d9cbe856..fc1727084 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3172,7 +3172,7 @@ def load_ipy(app): history_format_group.add_argument('-x', '--expanded', action='store_true', help='output expanded commands instead of entered command') history_format_group.add_argument('-v', '--verbose', action='store_true', help='display history and include expanded commands if they' - ' differ from the typed command.') + ' differ from the typed command') history_arg_help = ("empty all history items\n" "a one history item by number\n" diff --git a/tests/conftest.py b/tests/conftest.py index 7bc8e7d02..f41afb64d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,7 +28,7 @@ # Help text for base cmd2.Cmd application BASE_HELP = """Documented commands (type help ): ======================================== -alias help load py quit shell +alias help load py quit shell edit history macro pyscript set shortcuts """ # noqa: W291 @@ -50,7 +50,8 @@ """ # Help text for the history command -HELP_HISTORY = """Usage: history [-h] [-r | -e | -s | -o FILE | -t TRANSCRIPT | -v | -c] [arg] +HELP_HISTORY = """Usage: history [-h] [-r | -e | -o FILE | -t TRANSCRIPT | -c] [-s] [-x] [-v] + [arg] View, run, edit, save, or clear previously entered commands @@ -65,13 +66,17 @@ -h, --help show this help message and exit -r, --run run selected history items -e, --edit edit and then run selected history items - -s, --script output commands in script format -o, --output-file FILE - output commands to a script file + output commands to a script file, implies -s -t, --transcript TRANSCRIPT - output commands and results to a transcript file - -v, --verbose display history and include expanded commands if they differ from the typed command. + output commands and results to a transcript file, implies -s -c, --clear clear all history + +formatting: + -s, --script output commands in script format, i.e. without command numbers + -x, --expanded output expanded commands instead of entered command + -v, --verbose display history and include expanded commands if they differ from the typed command + """ # Output from the shortcuts command with default built-in shortcuts diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index bb415fe50..1b554fef7 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -56,10 +56,6 @@ def test_base_help_verbose(base_app): out = run_cmd(base_app, 'help --verbose') assert out == expected -def test_base_help_history(base_app): - out = run_cmd(base_app, 'help history') - assert out == normalize(HELP_HISTORY) - def test_base_argparse_help(base_app, capsys): # Verify that "set -h" gives the same output as "help set" and that it starts in a way that makes sense run_cmd(base_app, 'set -h') @@ -737,29 +733,6 @@ def test_base_py_interactive(base_app): m.assert_called_once() -def test_exclude_from_history(base_app, monkeypatch): - # Mock out the os.system call so we don't actually open an editor - m = mock.MagicMock(name='system') - monkeypatch.setattr("os.system", m) - - # Run edit command - run_cmd(base_app, 'edit') - - # Run history command - run_cmd(base_app, 'history') - - # Verify that the history is empty - out = run_cmd(base_app, 'history') - assert out == [] - - # Now run a command which isn't excluded from the history - run_cmd(base_app, 'help') - # And verify we have a history now ... - out = run_cmd(base_app, 'history') - expected = normalize(""" 1 help""") - assert out == expected - - def test_base_cmdloop_with_queue(): # Create a cmd2.Cmd() instance and make sure basic settings are like we want for test app = cmd2.Cmd() @@ -1946,20 +1919,12 @@ def test_parseline(base_app): assert line == statement.strip() -def test_readline_remove_history_item(base_app): - from cmd2.rl_utils import readline - assert readline.get_current_history_length() == 0 - readline.add_history('this is a test') - assert readline.get_current_history_length() == 1 - readline.remove_history_item(0) - assert readline.get_current_history_length() == 0 - def test_onecmd_raw_str_continue(base_app): line = "help" stop = base_app.onecmd(line) out = base_app.stdout.getvalue() assert not stop - assert out.strip() == BASE_HELP.strip() + assert normalize(out) == normalize(BASE_HELP) def test_onecmd_raw_str_quit(base_app): line = "quit" @@ -1969,78 +1934,6 @@ def test_onecmd_raw_str_quit(base_app): assert out == '' -@pytest.fixture(scope="session") -def hist_file(): - fd, filename = tempfile.mkstemp(prefix='hist_file', suffix='.txt') - os.close(fd) - yield filename - # teardown code - try: - os.remove(filename) - except FileNotFoundError: - pass - -def test_existing_history_file(hist_file, capsys): - import atexit - import readline - - # Create the history file before making cmd2 app - with open(hist_file, 'w'): - pass - - # Create a new cmd2 app - app = cmd2.Cmd(persistent_history_file=hist_file) - out, err = capsys.readouterr() - - # Make sure there were no errors - assert err == '' - - # Unregister the call to write_history_file that cmd2 did - atexit.unregister(readline.write_history_file) - - # Remove created history file - os.remove(hist_file) - - -def test_new_history_file(hist_file, capsys): - import atexit - import readline - - # Remove any existing history file - try: - os.remove(hist_file) - except OSError: - pass - - # Create a new cmd2 app - app = cmd2.Cmd(persistent_history_file=hist_file) - out, err = capsys.readouterr() - - # Make sure there were no errors - assert err == '' - - # Unregister the call to write_history_file that cmd2 did - atexit.unregister(readline.write_history_file) - - # Remove created history file - os.remove(hist_file) - -def test_bad_history_file_path(capsys, request): - # Use a directory path as the history file - test_dir = os.path.dirname(request.module.__file__) - - # Create a new cmd2 app - app = cmd2.Cmd(persistent_history_file=test_dir) - out, err = capsys.readouterr() - - if sys.platform == 'win32': - # pyreadline masks the read exception. Therefore the bad path error occurs when trying to write the file. - assert 'readline cannot write' in err - else: - # GNU readline raises an exception upon trying to read the directory as a file - assert 'readline cannot read' in err - - def test_get_all_commands(base_app): # Verify that the base app has the expected commands commands = base_app.get_all_commands() diff --git a/tests/test_history.py b/tests/test_history.py index 98d72512b..32b507cef 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -5,6 +5,7 @@ """ import tempfile import os +import sys import pytest @@ -17,7 +18,35 @@ import cmd2 from cmd2 import clipboard from cmd2 import utils -from .conftest import run_cmd, normalize +from .conftest import run_cmd, normalize, HELP_HISTORY + + +def test_base_help_history(base_app): + out = run_cmd(base_app, 'help history') + assert out == normalize(HELP_HISTORY) + +def test_exclude_from_history(base_app, monkeypatch): + # Mock out the os.system call so we don't actually open an editor + m = mock.MagicMock(name='system') + monkeypatch.setattr("os.system", m) + + # Run edit command + run_cmd(base_app, 'edit') + + # Run history command + run_cmd(base_app, 'history') + + # Verify that the history is empty + out = run_cmd(base_app, 'history') + assert out == [] + + # Now run a command which isn't excluded from the history + run_cmd(base_app, 'help') + # And verify we have a history now ... + out = run_cmd(base_app, 'history') + expected = normalize(""" 1 help""") + assert out == expected + @pytest.fixture def hist(): @@ -264,3 +293,89 @@ def test_history_script_expanded(base_app): output = run_cmd(base_app, 'history -sx') expected = ['alias create s shortcuts', 'shortcuts'] assert output == expected + + +##### +# +# readline tests +# +##### +def test_readline_remove_history_item(base_app): + from cmd2.rl_utils import readline + assert readline.get_current_history_length() == 0 + readline.add_history('this is a test') + assert readline.get_current_history_length() == 1 + readline.remove_history_item(0) + assert readline.get_current_history_length() == 0 + + +@pytest.fixture(scope="session") +def hist_file(): + fd, filename = tempfile.mkstemp(prefix='hist_file', suffix='.txt') + os.close(fd) + yield filename + # teardown code + try: + os.remove(filename) + except FileNotFoundError: + pass + +def test_existing_history_file(hist_file, capsys): + import atexit + import readline + + # Create the history file before making cmd2 app + with open(hist_file, 'w'): + pass + + # Create a new cmd2 app + app = cmd2.Cmd(persistent_history_file=hist_file) + out, err = capsys.readouterr() + + # Make sure there were no errors + assert err == '' + + # Unregister the call to write_history_file that cmd2 did + atexit.unregister(readline.write_history_file) + + # Remove created history file + os.remove(hist_file) + +def test_new_history_file(hist_file, capsys): + import atexit + import readline + + # Remove any existing history file + try: + os.remove(hist_file) + except OSError: + pass + + # Create a new cmd2 app + app = cmd2.Cmd(persistent_history_file=hist_file) + out, err = capsys.readouterr() + + # Make sure there were no errors + assert err == '' + + # Unregister the call to write_history_file that cmd2 did + atexit.unregister(readline.write_history_file) + + # Remove created history file + os.remove(hist_file) + +def test_bad_history_file_path(capsys, request): + # Use a directory path as the history file + test_dir = os.path.dirname(request.module.__file__) + + # Create a new cmd2 app + app = cmd2.Cmd(persistent_history_file=test_dir) + out, err = capsys.readouterr() + + if sys.platform == 'win32': + # pyreadline masks the read exception. Therefore the bad path error occurs when trying to write the file. + assert 'readline cannot write' in err + else: + # GNU readline raises an exception upon trying to read the directory as a file + assert 'readline cannot read' in err + From 740bf754ecbe457f77f6fd64f9128057f36091b7 Mon Sep 17 00:00:00 2001 From: kotfu Date: Sat, 9 Feb 2019 19:45:13 -0700 Subject: [PATCH 05/12] Fix flake errors --- cmd2/cmd2.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index fc1727084..ced8f62ad 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3168,8 +3168,10 @@ def load_ipy(app): history_action_group.add_argument('-c', '--clear', action='store_true', help='clear all history') history_format_group = history_parser.add_argument_group(title='formatting') - history_format_group.add_argument('-s', '--script', action='store_true', help='output commands in script format, i.e. without command numbers') - history_format_group.add_argument('-x', '--expanded', action='store_true', help='output expanded commands instead of entered command') + history_script_help = 'output commands in script format, i.e. without command numbers' + history_format_group.add_argument('-s', '--script', action='store_true', help=history_script_help) + history_expand_help = 'output expanded commands instead of entered command' + history_format_group.add_argument('-x', '--expanded', action='store_true', help=history_expand_help) history_format_group.add_argument('-v', '--verbose', action='store_true', help='display history and include expanded commands if they' ' differ from the typed command') @@ -3187,16 +3189,16 @@ def do_history(self, args: argparse.Namespace) -> None: # -v must be used alone with no other options if args.verbose: - if args.clear or args.edit or args.output_file or args.run or args.transcript or args.expanded or args.script: + if args.clear or args.edit or args.output_file or args.run or args.transcript or args.expanded or args.script: self.poutput("-v can not be used with any other options") self.poutput(self.history_parser.format_usage()) return # -s and -x can only be used if none of these options are present: [-c -r -e -o -t] if (args.script or args.expanded) and (args.clear or args.edit or args.output_file or args.run or args.transcript): - self.poutput("-s and -x can not be used with -c, -r, -e, -o, or -t") - self.poutput(self.history_parser.format_usage()) - return + self.poutput("-s and -x can not be used with -c, -r, -e, -o, or -t") + self.poutput(self.history_parser.format_usage()) + return if args.clear: # Clear command and readline history From e174e23a13fb1e75511eb05a346899925e45e5f0 Mon Sep 17 00:00:00 2001 From: kotfu Date: Sat, 16 Feb 2019 21:52:25 -0700 Subject: [PATCH 06/12] Fix incorrect example in alias help message --- cmd2/cmd2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index ced8f62ad..748183b70 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -2251,7 +2251,7 @@ def alias_list(self, args: argparse.Namespace) -> None: " would for the actual command the alias resolves to.\n" "\n" "Examples:\n" - " alias ls !ls -lF\n" + " alias create ls !ls -lF\n" " alias create show_log !cat \"log file.txt\"\n" " alias create save_results print_results \">\" out.txt\n") From 0abcb7038dfaf85717b82dedad92bfce44a5a283 Mon Sep 17 00:00:00 2001 From: kotfu Date: Sat, 16 Feb 2019 22:15:57 -0700 Subject: [PATCH 07/12] expanded history searches with string or regex for #545 history -v with a string or regex search now searches both the entered command as well as the expanded command --- cmd2/history.py | 5 +++-- tests/test_history.py | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/cmd2/history.py b/cmd2/history.py index ad4b23aa9..26ff5d4d8 100644 --- a/cmd2/history.py +++ b/cmd2/history.py @@ -154,7 +154,7 @@ def isin(hi): :param hi: HistoryItem :return: bool - True if search matches """ - return finder.search(hi) + return finder.search(hi) or finder.search(hi.expanded) else: def isin(hi): """Listcomp filter function for doing a case-insensitive string search of History. @@ -162,5 +162,6 @@ def isin(hi): :param hi: HistoryItem :return: bool - True if search matches """ - return utils.norm_fold(getme) in utils.norm_fold(hi) + srch = utils.norm_fold(getme) + return srch in utils.norm_fold(hi) or srch in utils.norm_fold(hi.expanded) return [itm for itm in self if isin(itm)] diff --git a/tests/test_history.py b/tests/test_history.py index 32b507cef..2edb89dc7 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -123,6 +123,31 @@ def test_history_with_string_argument(base_app): """) assert out == expected +def test_history_expanded_with_string_argument(base_app): + run_cmd(base_app, 'alias create sc shortcuts') + run_cmd(base_app, 'help') + run_cmd(base_app, 'help history') + run_cmd(base_app, 'sc') + out = run_cmd(base_app, 'history -v shortcuts') + expected = normalize(""" + 1 alias create sc shortcuts + 4 sc + 4x shortcuts +""") + assert out == expected + +def test_history_expanded_with_regex_argument(base_app): + run_cmd(base_app, 'alias create sc shortcuts') + run_cmd(base_app, 'help') + run_cmd(base_app, 'help history') + run_cmd(base_app, 'sc') + out = run_cmd(base_app, 'history -v /sh.*cuts/') + expected = normalize(""" + 1 alias create sc shortcuts + 4 sc + 4x shortcuts +""") + assert out == expected def test_history_with_integer_argument(base_app): run_cmd(base_app, 'help') From 40b03a52fb72895cb0b6651de567ff3a3d868405 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Tue, 26 Feb 2019 22:54:31 -0500 Subject: [PATCH 08/12] Fixed unit test which was slow on macOS and hung forever on Windows --- tests/test_history.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_history.py b/tests/test_history.py index 60ffdb3a5..b6dc66a28 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -26,9 +26,12 @@ def test_base_help_history(base_app): assert out == normalize(HELP_HISTORY) def test_exclude_from_history(base_app, monkeypatch): - # Mock out the os.system call so we don't actually open an editor - m = mock.MagicMock(name='system') - monkeypatch.setattr("os.system", m) + # Set a fake editor just to make sure we have one. We aren't really going to call it due to the mock + base_app.editor = 'fooedit' + + # Mock out the subprocess.Popen call so we don't actually open an editor + m = mock.MagicMock(name='Popen') + monkeypatch.setattr("subprocess.Popen", m) # Run edit command run_cmd(base_app, 'edit') @@ -42,6 +45,7 @@ def test_exclude_from_history(base_app, monkeypatch): # Now run a command which isn't excluded from the history run_cmd(base_app, 'help') + # And verify we have a history now ... out = run_cmd(base_app, 'history') expected = normalize(""" 1 help""") From 2f7a4ba73b23df5372a5f3ccd214cd4d290278f8 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 26 Feb 2019 22:58:48 -0500 Subject: [PATCH 09/12] Fixed comments --- tests/test_history.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_history.py b/tests/test_history.py index b6dc66a28..06c57b4cf 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -224,7 +224,7 @@ def test_history_edit(base_app, monkeypatch): # going to call it due to the mock base_app.editor = 'fooedit' - # Mock out the os.system call so we don't actually open an editor + # Mock out the Popen call so we don't actually open an editor m = mock.MagicMock(name='Popen') monkeypatch.setattr("subprocess.Popen", m) @@ -232,7 +232,7 @@ def test_history_edit(base_app, monkeypatch): run_cmd(base_app, 'help') run_cmd(base_app, 'history -e 1') - # We have an editor, so should expect a system call + # We have an editor, so should expect a Popen call m.assert_called_once() def test_history_run_all_commands(base_app): From 009cb87895c2e23521f2fe1b69252231fa010a5a Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sun, 3 Mar 2019 14:53:38 -0500 Subject: [PATCH 10/12] Potential fixes for outstanding multi-line issues in history command --- cmd2/cmd2.py | 12 +++++++++--- cmd2/constants.py | 1 + cmd2/history.py | 4 ++-- cmd2/parsing.py | 6 +++--- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index c404ee1d8..42cb58b48 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -302,7 +302,7 @@ class Cmd(cmd.Cmd): # Attributes used to configure the StatementParser, best not to change these at runtime multiline_commands = [] shortcuts = {'?': 'help', '!': 'shell', '@': 'load', '@@': '_relative_load'} - terminators = [';'] + terminators = [constants.MULTILINE_TERMINATOR] # Attributes which are NOT dynamically settable at runtime allow_cli_args = True # Should arguments passed on the command-line be processed as commands? @@ -3256,7 +3256,10 @@ def do_history(self, args: argparse.Namespace) -> None: fd, fname = tempfile.mkstemp(suffix='.txt', text=True) with os.fdopen(fd, 'w') as fobj: for command in history: - fobj.write('{}\n'.format(command)) + if command.statement.multiline_command: + fobj.write('{}\n'.format(command.expanded.rstrip())) + else: + fobj.write('{}\n'.format(command)) try: self.do_edit(fname) self.do_load(fname) @@ -3268,7 +3271,10 @@ def do_history(self, args: argparse.Namespace) -> None: try: with open(os.path.expanduser(args.output_file), 'w') as fobj: for command in history: - fobj.write('{}\n'.format(command)) + if command.statement.multiline_command: + fobj.write('{}\n'.format(command.expanded.rstrip())) + else: + fobj.write('{}\n'.format(command)) plural = 's' if len(history) > 1 else '' self.pfeedback('{} command{} saved to {}'.format(len(history), plural, args.output_file)) except Exception as e: diff --git a/cmd2/constants.py b/cmd2/constants.py index 3c133b70c..39115493a 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -12,6 +12,7 @@ REDIRECTION_APPEND = '>>' REDIRECTION_CHARS = [REDIRECTION_PIPE, REDIRECTION_OUTPUT] REDIRECTION_TOKENS = [REDIRECTION_PIPE, REDIRECTION_OUTPUT, REDIRECTION_APPEND] +MULTILINE_TERMINATOR = ';' # Regular expression to match ANSI escape codes ANSI_ESCAPE_RE = re.compile(r'\x1b[^m]*m') diff --git a/cmd2/history.py b/cmd2/history.py index 26ff5d4d8..45de34787 100644 --- a/cmd2/history.py +++ b/cmd2/history.py @@ -46,13 +46,13 @@ def pr(self, script=False, expanded=False, verbose=False) -> str: else: if script: # display without entry numbers - if expanded: + if expanded or self.statement.multiline_command: ret_str = self.expanded.rstrip() else: ret_str = str(self) else: # display a numbered list - if expanded: + if expanded or self.statement.multiline_command: ret_str = self.listformat.format(self.idx, self.expanded.rstrip()) else: ret_str = self.listformat.format(self.idx, str(self).rstrip()) diff --git a/cmd2/parsing.py b/cmd2/parsing.py index 070d37745..90484d76f 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -191,8 +191,8 @@ def command_and_args(self) -> str: def expanded_command_line(self) -> str: """Contains command_and_args plus any ending terminator, suffix, and redirection chars""" rtn = self.command_and_args - if self.terminator: - rtn += self.terminator + if self.multiline_command: + rtn += constants.MULTILINE_TERMINATOR if self.suffix: rtn += ' ' + self.suffix @@ -240,7 +240,7 @@ def __init__( ): self.allow_redirection = allow_redirection if terminators is None: - self.terminators = [';'] + self.terminators = [constants.MULTILINE_TERMINATOR] else: self.terminators = terminators if multiline_commands is None: From 73496248ba76cc58512142be3973a214ae9336f8 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sun, 3 Mar 2019 15:53:18 -0500 Subject: [PATCH 11/12] Fixed a couple bugs and added unit tests --- cmd2/parsing.py | 4 +++- tests/test_parsing.py | 9 ++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/cmd2/parsing.py b/cmd2/parsing.py index 90484d76f..2f22b607f 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -193,12 +193,14 @@ def expanded_command_line(self) -> str: rtn = self.command_and_args if self.multiline_command: rtn += constants.MULTILINE_TERMINATOR + elif self.terminator: + rtn += self.terminator if self.suffix: rtn += ' ' + self.suffix if self.pipe_to: - rtn += ' | ' + self.pipe_to + rtn += ' | ' + ' '.join(self.pipe_to) if self.output: rtn += ' ' + self.output diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 78adf8802..c341f9e38 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -10,8 +10,10 @@ import pytest import cmd2 -from cmd2.parsing import StatementParser from cmd2 import utils +from cmd2.constants import MULTILINE_TERMINATOR +from cmd2.parsing import StatementParser + @pytest.fixture def parser(): @@ -147,6 +149,7 @@ def test_parse_word_plus_terminator(parser, line, terminator): assert statement.argv == ['termbare'] assert not statement.arg_list assert statement.terminator == terminator + assert statement.expanded_command_line == statement.command + statement.terminator @pytest.mark.parametrize('line,terminator', [ ('termbare; suffx', ';'), @@ -163,6 +166,7 @@ def test_parse_suffix_after_terminator(parser, line, terminator): assert not statement.arg_list assert statement.terminator == terminator assert statement.suffix == 'suffx' + assert statement.expanded_command_line == statement.command + statement.terminator + ' ' + statement.suffix def test_parse_command_with_args(parser): line = 'command with args' @@ -258,6 +262,7 @@ def test_parse_simple_pipe(parser, line): assert statement.argv == ['simple'] assert not statement.arg_list assert statement.pipe_to == ['piped'] + assert statement.expanded_command_line == statement.command + ' | ' + ' '.join(statement.pipe_to) def test_parse_double_pipe_is_not_a_pipe(parser): line = 'double-pipe || is not a pipe' @@ -294,6 +299,7 @@ def test_parse_redirect(parser,line, output): assert statement.args == statement assert statement.output == output assert statement.output_to == 'out.txt' + assert statement.expanded_command_line == statement.command + ' ' + statement.output + ' ' + statement.output_to def test_parse_redirect_with_args(parser): line = 'output into > afile.txt' @@ -539,6 +545,7 @@ def test_parse_alias_on_multiline_command(parser): assert statement.args == statement assert statement == 'has > inside an unfinished command' assert statement.terminator == '' + assert statement.expanded_command_line == statement.multiline_command + ' ' + statement + MULTILINE_TERMINATOR @pytest.mark.parametrize('line,output', [ ('helpalias > out.txt', '>'), From 6370022f726fec17b28ca27d7d0602803f1c6cb6 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Mon, 4 Mar 2019 22:26:50 -0500 Subject: [PATCH 12/12] Updated CHANGELOG --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71fb35ec6..dd9966cc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ ## 0.9.11 (TBD, 2019) +* Bug Fixes + * Fixed bug in how **history** command deals with multiline commands when output to a script * Enhancements + * Improvements to the **history** command + * Simplified the display format and made it more similar to **bash** + * Added **-x**, **--expanded** flag + * output expanded commands instead of entered command (expands aliases, macros, and shortcuts) + * Added **-v**, **--verbose** flag + * display history and include expanded commands if they differ from the typed command * Added ``matches_sort_key`` to override the default way tab completion matches are sorted * Potentially breaking changes * Made ``cmd2_app`` a positional and required argument of ``AutoCompleter`` since certain functionality now