diff options
-rw-r--r-- | cmd2/cmd2.py | 21 | ||||
-rw-r--r-- | cmd2/history.py | 220 | ||||
-rw-r--r-- | docs/freefeatures.rst | 200 | ||||
-rw-r--r-- | tests/test_history.py | 92 | ||||
-rw-r--r-- | tests/test_transcript.py | 4 |
5 files changed, 406 insertions, 131 deletions
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 1767cc67..96b08f4e 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3231,15 +3231,22 @@ class Cmd(cmd.Cmd): # If a character indicating a slice is present, retrieve # a slice of the history arg = args.arg + arg_is_int = False + try: + _ = int(arg) + arg_is_int = True + except ValueError: + pass + if '..' in arg or ':' in arg: - try: - # Get a slice of history - history = self.history.span(arg) - except IndexError: - history = self.history.get(arg) + # Get a slice of history + history = self.history.span(arg) + elif arg_is_int: + history = [self.history.get(arg)] + elif arg.startswith(r'/') and arg.endswith(r'/'): + history = self.history.regex_search(arg) else: - # Get item(s) from history by index or string search - history = self.history.get(arg) + history = self.history.str_search(arg) else: # If no arg given, then retrieve the entire history cowardly_refuse_to_run = True diff --git a/cmd2/history.py b/cmd2/history.py index 45de3478..819989b1 100644 --- a/cmd2/history.py +++ b/cmd2/history.py @@ -5,7 +5,7 @@ History management classes import re -from typing import List, Optional, Union +from typing import List, Union from . import utils from .parsing import Statement @@ -60,108 +60,154 @@ class HistoryItem(str): class History(list): - """ A list of HistoryItems that knows how to respond to user requests. """ + """A list of HistoryItems that knows how to respond to user requests. + + Here are some key methods: + + select() - parse user input and return a list of relevant history items + str_search() - return a list of history items which contain the given string + regex_search() - return a list of history items which match a given regex + get() - return a single element of the list, using 1 based indexing + span() - given a 1-based slice, return the appropriate list of history items + + """ # noinspection PyMethodMayBeStatic - def _zero_based_index(self, onebased: int) -> int: + def _zero_based_index(self, onebased: Union[int, str]) -> int: """Convert a one-based index to a zero-based index.""" - result = onebased + result = int(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 + def append(self, new: Statement) -> None: + """Append a HistoryItem to end of the History list - spanpattern = re.compile(r'^\s*(?P<start>-?\d+)?\s*(?P<separator>:|(\.{2,}))?\s*(?P<end>-?\d+)?\s*$') + :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 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. + def get(self, index: Union[int, str]) -> HistoryItem: + """Get item from the History list using 1-based indexing. - :param raw: string potentially containing a span of the forms a..b, a:b, a:, ..b - :return: slice from the History list + :param index: optional item to get (index as either integer or string) + :return: a single HistoryItem """ - if raw.lower() in ('*', '-', 'all'): - raw = ':' - results = self.spanpattern.search(raw) - if not results: + index = int(index) + if index == 0: 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 + elif index < 0: + return self[index] + else: + return self[index - 1] + + # This regular expression parses input for the span() method. There are five parts: + # + # ^\s* matches any whitespace at the beginning of the + # input. This is here so you don't have to trim the input + # + # (?P<start>-?\d+)? create a capture group named 'start' which matches one + # or more digits, optionally preceeded by a minus sign. This + # group is optional so that we can match a string like '..2' + # + # (?P<separator>:|(\.{2,}))? create a capture group named 'separator' which matches either + # a colon or two periods. This group is optional so we can + # match a string like '3' + # + # (?P<end>-?\d+)? create a capture group named 'end' which matches one or more + # digits, optionally preceeded by a minus sign. This group is + # optional so that we can match a string like ':' or '5:' + # + # \s*$ match any whitespace at the end of the input. This is here so + # you don't have to trim the input + # + spanpattern = re.compile(r'^\s*(?P<start>-?\d+)?(?P<separator>:|(\.{2,}))?(?P<end>-?\d+)?\s*$') + + def span(self, span: str) -> List[HistoryItem]: + """Return an index or slice of the History list, + + :param raw: string containing an index or a slice + :return: a list of HistoryItems + + This method can accommodate input in any of these forms: + + a + -a + a..b or a:b + a.. or a: + ..a or :a + -a.. or -a: + ..-a or :-a + + Different from native python indexing and slicing of arrays, this method + uses 1-based array numbering. Users who are not programmers can't grok + 0 based numbering. Programmers can usually grok either. Which reminds me, + there are only two hard problems in programming: + + - naming + - cache invalidation + - off by one errors - rangePattern = re.compile(r'^\s*(?P<start>[\d]+)?\s*-\s*(?P<end>[\d]+)?\s*$') + """ + if span.lower() in ('*', '-', 'all'): + span = ':' + results = self.spanpattern.search(span) + if not results: + # our regex doesn't match the input, bail out + raise ValueError + + sep = results.group('separator') + start = results.group('start') + if start: + start = self._zero_based_index(start) + end = results.group('end') + if end: + end = int(end) + + if start is not None and end is not None: + # we have both start and end, return a slice of history, unless both are negative + if start < 0 and end < 0: + raise ValueError + result = self[start:end] + elif start is not None and sep is not None: + # take a slice of the array + result = self[start:] + elif end is not None and sep is not None: + result = self[:end] + elif start is not None: + # there was no separator so it's either a posative or negative integer + result = [self[start]] + else: + # we just have a separator, return the whole list + result = self[:] + return result - def append(self, new: Statement) -> None: - """Append a HistoryItem to end of the History list + def str_search(self, search: str) -> List[HistoryItem]: + """Find history items which contain a given string - :param new: command line to convert to HistoryItem and add to the end of the History list + :param search: the string to search for + :return: a list of history items, or an empty list if the string was not found """ - new = HistoryItem(new) - list.append(self, new) - new.idx = len(self) + def isin(history_item): + """filter function for string search of history""" + sloppy = utils.norm_fold(search) + return sloppy in utils.norm_fold(history_item) or sloppy in utils.norm_fold(history_item.expanded) + return [item for item in self if isin(item)] - def get(self, getme: Optional[Union[int, str]]=None) -> List[HistoryItem]: - """Get an item or items from the History list using 1-based indexing. + def regex_search(self, regex: str) -> List[HistoryItem]: + """Find history items which match a given regular expression - :param getme: optional item(s) to get (either an integer index or string to search for) - :return: list of HistoryItems matching the retrieval criteria + :param regex: the regular expression to search for. + :return: a list of history items, or an empty list if the string was not found """ - 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)] + regex = regex.strip() + if regex.startswith(r'/') and regex.endswith(r'/'): + regex = regex[1:-1] + finder = re.compile(regex, re.DOTALL | re.MULTILINE) + + def isin(hi): + """filter function for doing a regular expression search of history""" + return finder.search(hi) or finder.search(hi.expanded) + return [itm for itm in self if isin(itm)] diff --git a/docs/freefeatures.rst b/docs/freefeatures.rst index bcb9c0e7..1ae3c7ac 100644 --- a/docs/freefeatures.rst +++ b/docs/freefeatures.rst @@ -29,9 +29,9 @@ Simply include one command per line, typed exactly as you would inside a ``cmd2` Comments ======== -Any command line input where the first non-whitespace character is a # will be treated as a comment. -This means any # character appearing later in the command will be treated as a literal. The same -applies to a # in the middle of a multiline command, even if it is the first character on a line. +Any command line input where the first non-whitespace character is a `#` will be treated as a comment. +This means any `#` character appearing later in the command will be treated as a literal. The same +applies to a `#` in the middle of a multiline command, even if it is the first character on a line. Comments can be useful in :ref:`scripts`, but would be pointless within an interactive session. @@ -253,9 +253,198 @@ the readline history. .. automethod:: cmd2.cmd2.Cmd.__init__ -``cmd2`` makes a third type of history access available with the **history** command: +``cmd2`` makes a third type of history access available with the `history` command. Each time +the user enters a command, ``cmd2`` saves the input. The `history` command lets you do interesting +things with that saved input. The examples to follow all assume that you have entered the +following commands:: -.. automethod:: cmd2.cmd2.Cmd.do_history + (Cmd) alias create one !echo one + Alias 'one' created + (Cmd) alias create two !echo two + Alias 'two' created + (Cmd) alias create three !echo three + Alias 'three' created + (Cmd) alias create four !echo four + Alias 'four' created + +In it's simplest form, the `history` command displays previously entered commands. With no +additional arguments, it displays all previously entered commands:: + + (Cmd) history + 1 alias create one !echo one + 2 alias create two !echo two + 3 alias create three !echo three + 4 alias create four !echo four + +If you give a positive integer as an argument, then it only displays the specified command:: + + (Cmd) history 4 + 4 alias create four !echo four + +If you give a negative integer *N* as an argument, then it display the *Nth* last command. +For example, if you give `-1` it will display the last command you entered. If you give `-2` +it will display the next to last command you entered, and so forth:: + + (Cmd) history -2 + 3 alias create three !echo three + +You can use a similar mechanism to display a range of commands. Simply give two command numbers +separated by `..` or `:`, and you will see all commands between those two numbers:: + + (Cmd) history 2:3 + 2 alias create two !echo two + 3 alias create three !echo three + +If you omit the first number, it will start at the beginning. If you omit the last number, it +will continue to the end:: + + (Cmd) history :2 + 1 alias create one !echo one + 2 alias create two !echo two + (Cmd) history 2: + 2 alias create two !echo two + 3 alias create three !echo three + 4 alias create four !echo four + +You can use negative numbers as either the first or second number of the range (but not both). If +you want to display the last three commands entered:: + + (Cmd) history -- -3: + 2 alias create two !echo two + 3 alias create three !echo three + 4 alias create four !echo four + +Notice the double dashes. These are required because the history command uses `argparse` to parse +the command line arguments. For reasons I do not understand, `argparse` thinks `-3:` is an +option, not an argument, but it thinks `-3` is an argument. + +There is no zeroth command, so don't ask for it. If you are a python programmer, you've +probably noticed this looks a lot like the slice syntax for lists and arrays. It is, +with the exception that the first history command is 1, where the first element in +a python array is 0. + +Besides selecting previous commands by number, you can also search for them. You can use a simple +string search:: + + (Cmd) history two + 2 alias create two !echo two + +Or a regular expression search by enclosing your regex in slashes:: + + (Cmd) history '/te\ +th/' + 3 alias create three !echo three + +If your regular expression contains any characters that `argparse` finds +interesting, like dash or plus, you also need to enclose your regular expression +in quotation marks. + +This all sounds great, but doesn't it seem like a bit of overkill to have all +these ways to select commands if all we can do is display them? Turns out, +displaying history commands is just the beginning. The history command can +perform many other actions: + +- running previously entered commands +- saving previously entered commands to a text file +- opening previously entered commands in your favorite text editor +- running previously entered commands, saving the commands and their output to a text file +- clearing the history of entered commands + +Each of these actions is invoked using a command line option. The `-r` or +`--run` option runs one or more previously entered commands. To run command +number 1:: + + (Cmd) history --run 1 + +To rerun the last two commands (there's that double dash again to make argparse +stop looking for options):: + + (Cmd) history -r -- -2: + +Say you want to re-run some previously entered commands, but you would really +like to make a few changes to them before doing so. When you use the `-e` or +`--edit` option, `history` will write the selected commands out to a text file, +and open that file with a text editor. You make whatever changes, additions, or +deletions, you want. When you leave the text editor, all the commands in the +file are executed. To edit and then re-run commands 2-4 you would:: + + (Cmd) history --edit 2:4 + +If you want to save the commands to a text file, but not edit and re-run them, +use the `-o` or `--output-file` option. This is a great way to create +:ref:`scripts`, which can be loaded and executed using the `load` command. To +save the first 5 commands entered in this session to a text file:: + + (Cmd) history :5 -o history.txt + +The `history` command can also save both the commands and their output to a text +file. This is called a transcript. See :doc:`transcript` for more information on +how transcripts work, and what you can use them for. To create a transcript use +the `-t` or `--transcription` option:: + + (Cmd) history 2:3 --transcript transcript.txt + +The `--transcript` option implies `--run`: the commands must be re-run in order +to capture their output to the transcript file. + +The last action the history command can perform is to clear the command history +using `-c` or `--clear`:: + + (Cmd) history -c + +In addition to these five actions, the `history` command also has some options +to control how the output is formatted. With no arguments, the `history` command +displays the command number before each command. This is great when displaying +history to the screen because it gives you an easy reference to identify +previously entered commands. However, when creating a script or a transcript, +the command numbers would prevent the script from loading properly. The `-s` or +`--script` option instructs the `history` command to suppress the line numbers. +This option is automatically set by the `--output-file`, `--transcript`, and +`--edit` options. If you want to output the history commands with line numbers +to a file, you can do it with output redirection:: + + (Cmd) history 1:4 > history.txt + +You might use `-s` or `--script` on it's own if you want to display history +commands to the screen without line numbers, so you can copy them to the +clipboard:: + + (Cmd) history -s 1:3 + +`cmd2` supports both aliases and macros, which allow you to substitute a short, +more convenient input string with a longer replacement string. Say we create an +alias like this, and then use it:: + + (Cmd) alias create ls shell ls -aF + Alias 'ls' created + (Cmd) ls -d h* + history.txt htmlcov/ + +By default, the `history` command shows exactly what we typed:: + + (Cmd) history + 1 alias create ls shell ls -aF + 2 ls -d h* + +There are two ways to modify that display so you can see what aliases and macros +were expanded to. The first is to use `-x` or `--expanded`. These options show +the expanded command instead of the entered command:: + + (Cmd) history -x + 1 alias create ls shell ls -aF + 2 shell ls -aF -d h* + +If you want to see both the entered command and the expanded command, use the +`-v` or `--verbose` option:: + + (Cmd) history -v + 1 alias create ls shell ls -aF + 2 ls -d h* + 2x shell ls -aF -d h* + +If the entered command had no expansion, it is displayed as usual. However, if +there is some change as the result of expanding macros and aliases, then the +entered command is displayed with the number, and the expanded command is +displayed with the number followed by an `x`. .. _`Readline Emacs editing mode`: http://readline.kablamo.org/emacs.html @@ -279,7 +468,6 @@ with automatically included ``do_`` methods. ( ``!`` is a shortcut for ``shell``; thus ``!ls`` is equivalent to ``shell ls``.) - Transcript-based testing ======================== diff --git a/tests/test_history.py b/tests/test_history.py index 06c57b4c..1554df5e 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -63,26 +63,62 @@ def hist(): 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 + for tryit in ['*', ':', '-', 'all', 'ALL']: + assert hist.span(tryit) == hist + + assert hist.span('3') == ['third'] + assert hist.span('-1') == ['fourth'] + + assert hist.span('2..') == ['second', 'third', 'fourth'] + assert hist.span('2:') == ['second', 'third', 'fourth'] + + assert hist.span('-2..') == ['third', 'fourth'] + assert hist.span('-2:') == ['third', 'fourth'] + + assert hist.span('1..3') == ['first', 'second', 'third'] + assert hist.span('1:3') == ['first', 'second', 'third'] + + assert hist.span(':-2') == ['first', 'second'] + assert hist.span('..-2') == ['first', 'second'] + + value_errors = ['fred', 'fred:joe', 'a..b', '2 ..', '1 : 3', '-2..-3' ] + for tryit in value_errors: + with pytest.raises(ValueError): + hist.span(tryit) 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" + assert hist.get('1') == 'first' + assert hist.get(3) == 'third' + assert hist.get('-2') == hist[-2] + assert hist.get(-1) == 'fourth' + + with pytest.raises(IndexError): + hist.get(0) + with pytest.raises(IndexError): + hist.get('0') + + with pytest.raises(IndexError): + hist.get('5') + with pytest.raises(ValueError): + hist.get('2-3') + with pytest.raises(ValueError): + hist.get('1..2') + with pytest.raises(ValueError): + hist.get('3:4') + with pytest.raises(ValueError): + hist.get('fred') + with pytest.raises(ValueError): + hist.get('') + with pytest.raises(TypeError): + hist.get(None) + +def test_history_str_search(hist): + assert hist.str_search('ir') == ['first', 'third'] + assert hist.str_search('rth') == ['fourth'] + +def test_history_regex_search(hist): + assert hist.regex_search('/i.*d/') == ['third'] + assert hist.regex_search('s[a-z]+ond') == ['second'] def test_base_history(base_app): run_cmd(base_app, 'help') @@ -200,11 +236,9 @@ 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 + with pytest.raises(ValueError): + _, err = run_cmd(base_app, 'history "hal :"') + assert "ValueError" in err def test_history_output_file(base_app): run_cmd(base_app, 'help') @@ -358,8 +392,8 @@ def test_existing_history_file(hist_file, capsys): pass # Create a new cmd2 app - app = cmd2.Cmd(persistent_history_file=hist_file) - out, err = capsys.readouterr() + cmd2.Cmd(persistent_history_file=hist_file) + _, err = capsys.readouterr() # Make sure there were no errors assert err == '' @@ -381,8 +415,8 @@ def test_new_history_file(hist_file, capsys): pass # Create a new cmd2 app - app = cmd2.Cmd(persistent_history_file=hist_file) - out, err = capsys.readouterr() + cmd2.Cmd(persistent_history_file=hist_file) + _, err = capsys.readouterr() # Make sure there were no errors assert err == '' @@ -398,8 +432,8 @@ def test_bad_history_file_path(capsys, request): 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() + cmd2.Cmd(persistent_history_file=test_dir) + _, err = capsys.readouterr() if sys.platform == 'win32': # pyreadline masks the read exception. Therefore the bad path error occurs when trying to write the file. diff --git a/tests/test_transcript.py b/tests/test_transcript.py index 6bfe187e..f93642b8 100644 --- a/tests/test_transcript.py +++ b/tests/test_transcript.py @@ -53,7 +53,7 @@ class CmdLineApp(cmd2.Cmd): if opts.shout: arg = arg.upper() repetitions = opts.repeat or 1 - for i in range(min(repetitions, self.maxrepeats)): + for _ in range(min(repetitions, self.maxrepeats)): self.poutput(arg) # recommend using the poutput function instead of # self.stdout.write or "print", because Cmd allows the user @@ -69,7 +69,7 @@ class CmdLineApp(cmd2.Cmd): """Mumbles what you tell me to.""" repetitions = opts.repeat or 1 #arg = arg.split() - for i in range(min(repetitions, self.maxrepeats)): + for _ in range(min(repetitions, self.maxrepeats)): output = [] if random.random() < .33: output.append(random.choice(self.MUMBLE_FIRST)) |