diff options
-rw-r--r-- | cmd2/cmd2.py | 183 | ||||
-rw-r--r-- | cmd2/history.py | 167 | ||||
-rw-r--r-- | cmd2/parsing.py | 20 | ||||
-rw-r--r-- | tests/conftest.py | 16 | ||||
-rw-r--r-- | tests/test_cmd2.py | 314 | ||||
-rw-r--r-- | tests/test_history.py | 410 | ||||
-rw-r--r-- | tests/transcripts/from_cmdloop.txt | 18 |
7 files changed, 651 insertions, 477 deletions
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 24e140fd..c404ee1d 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -49,6 +49,7 @@ from . import utils 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 @@ -290,30 +291,6 @@ class EmptyStatement(Exception): pass -class HistoryItem(str): - """Class used to represent an item in the History list. - - 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. - - """ - listformat = '-------------------------[{}]\n{}\n' - - # noinspection PyUnusedLocal - def __init__(self, instr: str) -> None: - str.__init__(self) - self.lowercase = self.lower() - self.idx = None - - def pr(self) -> 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()) - - class Cmd(cmd.Cmd): """An easy but powerful framework for writing line-oriented command interpreters. @@ -2007,7 +1984,7 @@ class Cmd(cmd.Cmd): 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) @@ -2070,7 +2047,7 @@ class Cmd(cmd.Cmd): """ 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: @@ -2283,7 +2260,7 @@ class Cmd(cmd.Cmd): " 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") @@ -3188,18 +3165,27 @@ class Cmd(cmd.Cmd): 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('-c', '--clear', action="store_true", help='clear all history') + 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_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') + 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" @@ -3211,6 +3197,19 @@ class Cmd(cmd.Cmd): 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() @@ -3279,10 +3278,7 @@ class Cmd(cmd.Cmd): else: # Display the history items retrieved for hi in history: - if args.script: - self.poutput(hi) - else: - self.poutput(hi.pr()) + 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.""" @@ -3807,113 +3803,6 @@ class Cmd(cmd.Cmd): 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<start>-?\d+)?\s*(?P<separator>:|(\.{2,}))?\s*(?P<end>-?\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<start>[\d]+)?\s*-\s*(?P<end>[\d]+)?\s*$') - - def append(self, new: str) -> 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 00000000..26ff5d4d --- /dev/null +++ b/cmd2/history.py @@ -0,0 +1,167 @@ +# 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 = ' {:>4}x {}\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, 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 + """ + 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 + + +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<start>-?\d+)?\s*(?P<separator>:|(\.{2,}))?\s*(?P<end>-?\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<start>[\d]+)?\s*-\s*(?P<end>[\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) or finder.search(hi.expanded) + 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 + """ + 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/cmd2/parsing.py b/cmd2/parsing.py index d4f82ac9..070d3774 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -188,6 +188,26 @@ class Statement(str): 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 223389b9..f41afb64 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,7 +28,7 @@ except ImportError: # Help text for base cmd2.Cmd application BASE_HELP = """Documented commands (type help <topic>): ======================================== -alias help load py quit shell +alias help load py quit shell edit history macro pyscript set shortcuts """ # noqa: W291 @@ -50,7 +50,8 @@ shortcuts List available shortcuts """ # 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 | -o FILE | -t TRANSCRIPT | -c] [-s] [-x] [-v] + [arg] View, run, edit, save, or clear previously entered commands @@ -65,12 +66,17 @@ optional arguments: -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 + 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 faef21f9..8d0d56c6 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') @@ -302,206 +298,6 @@ def test_base_error(base_app): assert "is not a recognized command" in out[0] -@pytest.fixture -def hist(): - from cmd2.cmd2 import History, HistoryItem - h = History([HistoryItem('first'), HistoryItem('second'), HistoryItem('third'), HistoryItem('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 subprocess.Popen call so we don't actually open an editor - m = mock.MagicMock(name='Popen') - monkeypatch.setattr("subprocess.Popen", 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 Popen 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') @@ -935,34 +731,6 @@ def test_base_py_interactive(base_app): m.assert_called_once() -def test_exclude_from_history(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 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') - - # 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() @@ -2152,20 +1920,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" @@ -2175,78 +1935,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 new file mode 100644 index 00000000..06c57b4c --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,410 @@ +# coding=utf-8 +# flake8: noqa E302 +""" +Test history functions of cmd2 +""" +import tempfile +import os +import sys + +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, 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): + # 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') + + # 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(): + 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_class_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_class_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_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') + 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 Popen call so we don't actually open an editor + m = mock.MagicMock(name='Popen') + monkeypatch.setattr("subprocess.Popen", 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 Popen 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_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 + + +##### +# +# 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 + diff --git a/tests/transcripts/from_cmdloop.txt b/tests/transcripts/from_cmdloop.txt index 8c0dd007..871b71f1 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 |