diff options
author | Todd Leonhardt <todd.leonhardt@gmail.com> | 2019-03-04 22:48:33 -0500 |
---|---|---|
committer | Todd Leonhardt <todd.leonhardt@gmail.com> | 2019-03-04 22:48:33 -0500 |
commit | d1a970bc5853aa6c1c52db923fb9b4d12ada7cf2 (patch) | |
tree | 0f664834a86d6543287cf89ace609f3c0b3ae535 | |
parent | f765be2ce360cf104999f1440f1b13e75cbd957c (diff) | |
parent | dddf5d03c6aa9d7a4f6e953fe1529fa3d7743e39 (diff) | |
download | cmd2-git-d1a970bc5853aa6c1c52db923fb9b4d12ada7cf2.tar.gz |
Merged master into this branch and resolved conflicts in CHANGELOG
-rw-r--r-- | CHANGELOG.md | 7 | ||||
-rw-r--r-- | cmd2/cmd2.py | 193 | ||||
-rw-r--r-- | cmd2/constants.py | 1 | ||||
-rw-r--r-- | cmd2/history.py | 167 | ||||
-rw-r--r-- | cmd2/parsing.py | 24 | ||||
-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/test_parsing.py | 8 | ||||
-rw-r--r-- | tests/transcripts/from_cmdloop.txt | 18 |
10 files changed, 677 insertions, 481 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 4903a89f..fcf90db8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,14 @@ ## 0.9.11 (TBD, 2019) * Bug Fixes + * Fixed bug in how **history** command deals with multiline commands when output to a script * Fixed a bug when the ``with_argument_list`` decorator is called with the optional ``preserve_quotes`` argument * 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 diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 1bfeba1d..66de8473 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 @@ -295,30 +296,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. @@ -330,7 +307,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? @@ -2012,7 +1989,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) @@ -2075,7 +2052,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: @@ -3193,18 +3170,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" @@ -3216,6 +3202,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() @@ -3262,7 +3261,10 @@ class Cmd(cmd.Cmd): 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) @@ -3274,7 +3276,10 @@ class Cmd(cmd.Cmd): 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: @@ -3284,10 +3289,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.""" @@ -3812,113 +3814,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/constants.py b/cmd2/constants.py index 3e35a542..854862c5 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -13,6 +13,7 @@ REDIRECTION_APPEND = '>>' REDIRECTION_CHARS = [REDIRECTION_PIPE, REDIRECTION_OUTPUT] REDIRECTION_TOKENS = [REDIRECTION_PIPE, REDIRECTION_OUTPUT, REDIRECTION_APPEND] COMMENT_CHAR = '#' +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 new file mode 100644 index 00000000..45de3478 --- /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 or self.statement.multiline_command: + ret_str = self.expanded.rstrip() + else: + ret_str = str(self) + else: + # display a numbered list + 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()) + 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 bd3a6900..5ec13fb7 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -188,6 +188,28 @@ 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.multiline_command: + rtn += constants.MULTILINE_TERMINATOR + elif self.terminator: + rtn += self.terminator + + if self.suffix: + rtn += ' ' + self.suffix + + if self.pipe_to: + rtn += ' | ' + ' '.join(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. @@ -220,7 +242,7 @@ class StatementParser: ): 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: 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 af1d41c9..d43f7786 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -55,10 +55,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') @@ -301,206 +297,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') @@ -934,34 +730,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() @@ -2174,20 +1942,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" @@ -2197,78 +1957,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/test_parsing.py b/tests/test_parsing.py index de49d3f5..85ee0765 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -10,8 +10,9 @@ import attr import pytest import cmd2 -from cmd2.parsing import StatementParser from cmd2 import constants, utils +from cmd2.constants import MULTILINE_TERMINATOR +from cmd2.parsing import StatementParser @pytest.fixture def parser(): @@ -147,6 +148,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 +165,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' @@ -222,6 +225,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' @@ -258,6 +262,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' @@ -479,6 +484,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', '>'), 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 |