From 16d8e96846b175b3910d0a40f5907939fc04a217 Mon Sep 17 00:00:00 2001 From: kotfu Date: Sat, 9 Mar 2019 12:25:45 -0700 Subject: [PATCH 1/8] Reworked get() on History class --- cmd2/history.py | 91 +++++++++++++++++++++++++++---------------- tests/test_history.py | 38 ++++++++++++++---- 2 files changed, 87 insertions(+), 42 deletions(-) diff --git a/cmd2/history.py b/cmd2/history.py index 45de34787..77b1da513 100644 --- a/cmd2/history.py +++ b/cmd2/history.py @@ -60,7 +60,17 @@ def pr(self, script=False, expanded=False, verbose=False) -> 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: @@ -80,10 +90,30 @@ def _to_index(self, raw: str) -> Optional[int]: spanpattern = re.compile(r'^\s*(?P-?\d+)?\s*(?P:|(\.{2,}))?\s*(?P-?\d+)?\s*$') def span(self, raw: str) -> List[HistoryItem]: - """Parses the input string search for a span pattern and if if found, returns a slice from the History list. + """Parses the input string and return a slice from the History list. + + :param raw: string potentially containing a span + :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 grok either. Which reminds me, there + are only two hard problems in programming: + + - naming + - cache invalidation + - off by one errors - :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 = ':' @@ -116,37 +146,30 @@ def append(self, new: Statement) -> None: 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. + def get(self, index: Union[int, str]) -> HistoryItem: + """Get item 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 + :param index: optional item to get (index as either integer or string) + :return: a single HistoryItem """ - 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) + index = int(index) + if index == 0: + raise IndexError + elif index < 0: + return self[index] + else: + return self[index - 1] + + + + def str_search(self, search: str) -> List[HistoryItem]: + pass + + def regex_search(self, regex: str) -> List[HistoryItem]: + regex = regex.strip() + + if regex.startswith(r'/') and regex.endswith(r'/'): + finder = re.compile(regex[1:-1], re.DOTALL | re.MULTILINE | re.IGNORECASE) def isin(hi): """Listcomp filter function for doing a regular expression search of History. @@ -162,6 +185,6 @@ def isin(hi): :param hi: HistoryItem :return: bool - True if search matches """ - srch = utils.norm_fold(getme) + srch = utils.norm_fold(regex) return srch in utils.norm_fold(hi) or srch in utils.norm_fold(hi.expanded) return [itm for itm in self if isin(itm)] diff --git a/tests/test_history.py b/tests/test_history.py index 06c57b4cf..e354a711f 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -75,14 +75,36 @@ def test_history_class_span(hist): 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" + 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.get('ir') == ['first', 'third'] + +def test_history_regex_search(hist): + assert hist.get('/i.*d/') == ['third'] def test_base_history(base_app): run_cmd(base_app, 'help') From d8ef258758be7ca690c591a762cbed7ee4c5a838 Mon Sep 17 00:00:00 2001 From: kotfu Date: Sat, 9 Mar 2019 23:18:39 -0700 Subject: [PATCH 2/8] Clean up unused variables --- tests/test_transcript.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_transcript.py b/tests/test_transcript.py index 6bfe187ea..f93642b82 100644 --- a/tests/test_transcript.py +++ b/tests/test_transcript.py @@ -53,7 +53,7 @@ def do_speak(self, opts, arg): 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 @@ def do_mumble(self, opts, arg): """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)) From dfe5864fb2ca9334bb1b6e729ec31f5f5890f1cb Mon Sep 17 00:00:00 2001 From: kotfu Date: Sat, 9 Mar 2019 23:19:16 -0700 Subject: [PATCH 3/8] Clean up history command --- cmd2/cmd2.py | 21 +++-- cmd2/history.py | 183 ++++++++++++++++++++++++------------------ tests/test_history.py | 58 +++++++------ 3 files changed, 152 insertions(+), 110 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index b3a61212d..8dafffaf6 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3213,15 +3213,22 @@ def do_history(self, args: argparse.Namespace) -> None: # 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 77b1da513..819989b13 100644 --- a/cmd2/history.py +++ b/cmd2/history.py @@ -5,7 +5,7 @@ import re -from typing import List, Optional, Union +from typing import List, Union from . import utils from .parsing import Statement @@ -73,26 +73,62 @@ class History(list): """ # 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-?\d+)?\s*(?P:|(\.{2,}))?\s*(?P-?\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 and return 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 + :param index: optional item to get (index as either integer or string) + :return: a single HistoryItem + """ + index = int(index) + if index == 0: + raise IndexError + 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-?\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:|(\.{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-?\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-?\d+)?(?P:|(\.{2,}))?(?P-?\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: @@ -107,84 +143,71 @@ def span(self, raw: str) -> List[HistoryItem]: 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 grok either. Which reminds me, there - are only two hard problems in programming: + 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 """ - if raw.lower() in ('*', '-', 'all'): - raw = ':' - results = self.spanpattern.search(raw) + if span.lower() in ('*', '-', 'all'): + span = ':' + results = self.spanpattern.search(span) 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() + # 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 - rangePattern = re.compile(r'^\s*(?P[\d]+)?\s*-\s*(?P[\d]+)?\s*$') - - def append(self, new: Statement) -> None: - """Append a HistoryItem to end of the History list - - :param new: command line to convert to HistoryItem and add to the end of the History list - """ - new = HistoryItem(new) - list.append(self, new) - new.idx = len(self) - - def get(self, index: Union[int, str]) -> HistoryItem: - """Get item from the History list using 1-based indexing. + def str_search(self, search: str) -> List[HistoryItem]: + """Find history items which contain a given string - :param index: optional item to get (index as either integer or string) - :return: a single HistoryItem + :param search: the string to search for + :return: a list of history items, or an empty list if the string was not found """ - index = int(index) - if index == 0: - raise IndexError - elif index < 0: - return self[index] - else: - return self[index - 1] - - - - def str_search(self, search: str) -> List[HistoryItem]: - pass + 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 regex_search(self, regex: str) -> List[HistoryItem]: - regex = regex.strip() - - if regex.startswith(r'/') and regex.endswith(r'/'): - finder = re.compile(regex[1:-1], re.DOTALL | re.MULTILINE | re.IGNORECASE) + """Find history items which match a given regular expression - 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(regex) - return srch in utils.norm_fold(hi) or srch in utils.norm_fold(hi.expanded) - return [itm for itm in self if isin(itm)] + :param regex: the regular expression to search for. + :return: a list of history items, or an empty list if the string was not found + """ + 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/tests/test_history.py b/tests/test_history.py index e354a711f..1554df5e3 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -63,16 +63,28 @@ 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): assert hist.get('1') == 'first' @@ -101,10 +113,12 @@ def test_history_class_get(hist): hist.get(None) def test_history_str_search(hist): - assert hist.get('ir') == ['first', 'third'] + assert hist.str_search('ir') == ['first', 'third'] + assert hist.str_search('rth') == ['fourth'] def test_history_regex_search(hist): - assert hist.get('/i.*d/') == ['third'] + 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') @@ -222,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') @@ -380,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 == '' @@ -403,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 == '' @@ -420,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. From 1c302bc191b8370a3e85399ec65a20670dc83b8f Mon Sep 17 00:00:00 2001 From: kotfu Date: Sun, 10 Mar 2019 15:50:05 -0600 Subject: [PATCH 4/8] Add documentation for the history command. --- docs/freefeatures.rst | 200 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 194 insertions(+), 6 deletions(-) diff --git a/docs/freefeatures.rst b/docs/freefeatures.rst index 5c246798c..d661e0588 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. @@ -269,9 +269,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 @@ -295,7 +484,6 @@ with automatically included ``do_`` methods. ( ``!`` is a shortcut for ``shell``; thus ``!ls`` is equivalent to ``shell ls``.) - Transcript-based testing ======================== From 486f08a8dd3477cb0193cd6e739f556c9de30eaf Mon Sep 17 00:00:00 2001 From: kotfu Date: Sun, 10 Mar 2019 18:34:20 -0600 Subject: [PATCH 5/8] Formatting clean up on history section --- docs/freefeatures.rst | 128 ++++++++++++++++++++++-------------------- 1 file changed, 67 insertions(+), 61 deletions(-) diff --git a/docs/freefeatures.rst b/docs/freefeatures.rst index 1ae3c7ac6..b5c9991a9 100644 --- a/docs/freefeatures.rst +++ b/docs/freefeatures.rst @@ -253,10 +253,10 @@ the readline history. .. automethod:: cmd2.cmd2.Cmd.__init__ -``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:: +``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:: (Cmd) alias create one !echo one Alias 'one' created @@ -267,8 +267,9 @@ following commands:: (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:: +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 @@ -276,27 +277,30 @@ additional arguments, it displays all previously entered commands:: 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:: +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:: +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:: +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:: +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 @@ -306,25 +310,26 @@ will continue to the end:: 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:: +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. +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. +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:: +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 @@ -334,7 +339,7 @@ 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 +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. @@ -349,8 +354,8 @@ perform many other actions: - 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 +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 @@ -361,80 +366,81 @@ 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:: +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 +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:: +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 ``--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`:: +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:: +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 +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:: +``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:: +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:: +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:: +``-v`` or ``--verbose`` option:: (Cmd) history -v 1 alias create ls shell ls -aF @@ -444,7 +450,7 @@ If you want to see both the entered command and the expanded command, use the 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`. +displayed with the number followed by an ``x``. .. _`Readline Emacs editing mode`: http://readline.kablamo.org/emacs.html From 0ff0dfe37b7a9e6998a133452d4718a119b82cf7 Mon Sep 17 00:00:00 2001 From: kotfu Date: Sun, 10 Mar 2019 21:38:12 -0600 Subject: [PATCH 6/8] Incorporating requested changes from the PR --- cmd2/cmd2.py | 2 +- docs/freefeatures.rst | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 96b08f4e2..0c91afdf9 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3233,7 +3233,7 @@ def do_history(self, args: argparse.Namespace) -> None: arg = args.arg arg_is_int = False try: - _ = int(arg) + int(arg) arg_is_int = True except ValueError: pass diff --git a/docs/freefeatures.rst b/docs/freefeatures.rst index b5c9991a9..027caa910 100644 --- a/docs/freefeatures.rst +++ b/docs/freefeatures.rst @@ -319,9 +319,13 @@ You can use negative numbers as either the first or second number of the range 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. +``argparse`` to parse the command line arguments. As described in the `argparse +documentation `_ , ``-3:`` is +an option, not an argument: + + If you have positional arguments that must begin with - and don’t look like + negative numbers, you can insert the pseudo-argument '--' which tells + parse_args() that everything after that is a positional 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 From a4ff1a45b205171663559b0f7003ecf84face2aa Mon Sep 17 00:00:00 2001 From: kotfu Date: Sun, 10 Mar 2019 21:43:20 -0600 Subject: [PATCH 7/8] Allow both history indices to be negative --- cmd2/history.py | 4 +--- docs/freefeatures.rst | 3 +-- tests/test_history.py | 5 ++++- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cmd2/history.py b/cmd2/history.py index 819989b13..04baf0146 100644 --- a/cmd2/history.py +++ b/cmd2/history.py @@ -167,9 +167,7 @@ def span(self, span: str) -> List[HistoryItem]: 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 + # we have both start and end, return a slice of history result = self[start:end] elif start is not None and sep is not None: # take a slice of the array diff --git a/docs/freefeatures.rst b/docs/freefeatures.rst index 027caa910..4bfeffd03 100644 --- a/docs/freefeatures.rst +++ b/docs/freefeatures.rst @@ -310,8 +310,7 @@ last number, it will continue to the end:: 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:: +If you want to display the last three commands entered:: (Cmd) history -- -3: 2 alias create two !echo two diff --git a/tests/test_history.py b/tests/test_history.py index 1554df5e3..d9918b7a2 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -77,11 +77,14 @@ def test_history_class_span(hist): assert hist.span('1..3') == ['first', 'second', 'third'] assert hist.span('1:3') == ['first', 'second', 'third'] + assert hist.span('2:-1') == ['second', 'third'] + assert hist.span('-3:4') == ['second', 'third','fourth'] + assert hist.span('-4:-2') == ['first', 'second'] assert hist.span(':-2') == ['first', 'second'] assert hist.span('..-2') == ['first', 'second'] - value_errors = ['fred', 'fred:joe', 'a..b', '2 ..', '1 : 3', '-2..-3' ] + value_errors = ['fred', 'fred:joe', 'a..b', '2 ..', '1 : 3'] for tryit in value_errors: with pytest.raises(ValueError): hist.span(tryit) From b84798cfb2e0da5422936dfcf406c227b6a09723 Mon Sep 17 00:00:00 2001 From: kotfu Date: Mon, 11 Mar 2019 20:32:21 -0600 Subject: [PATCH 8/8] Negative ending history indices include the referenced command, instead of excluding it --- cmd2/history.py | 33 ++++++++++++++++++++++++--------- docs/freefeatures.rst | 5 +++-- tests/test_history.py | 10 +++++----- 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/cmd2/history.py b/cmd2/history.py index 04baf0146..729cc6e35 100644 --- a/cmd2/history.py +++ b/cmd2/history.py @@ -97,7 +97,7 @@ def get(self, index: Union[int, str]) -> HistoryItem: """ index = int(index) if index == 0: - raise IndexError + raise IndexError('The first command in history is command 1.') elif index < 0: return self[index] else: @@ -108,22 +108,27 @@ def get(self, index: Union[int, str]) -> HistoryItem: # ^\s* matches any whitespace at the beginning of the # input. This is here so you don't have to trim the input # - # (?P-?\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-?[1-9]{1}\d*)? create a capture group named 'start' which matches an + # optional minus sign, followed by exactly one non-zero + # digit, and as many other digits as you want. This group + # is optional so that we can match an input string like '..2'. + # This regex will match 1, -1, 10, -10, but not 0 or -0. # # (?P:|(\.{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-?\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:' + # (?P-?[1-9]{1}\d*)? create a capture group named 'end' which matches an + # optional minus sign, followed by exactly one non-zero + # digit, and as many other digits as you want. This group is + # optional so that we can match an input string like ':' + # or '5:'. This regex will match 1, -1, 10, -10, but not + # 0 or -0. # # \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-?\d+)?(?P:|(\.{2,}))?(?P-?\d+)?\s*$') + spanpattern = re.compile(r'^\s*(?P-?[1-9]{1}\d*)?(?P:|(\.{2,}))?(?P-?[1-9]{1}\d*)?\s*$') def span(self, span: str) -> List[HistoryItem]: """Return an index or slice of the History list, @@ -156,7 +161,7 @@ def span(self, span: str) -> List[HistoryItem]: results = self.spanpattern.search(span) if not results: # our regex doesn't match the input, bail out - raise ValueError + raise ValueError('History indices must be positive or negative integers, and may not be zero.') sep = results.group('separator') start = results.group('start') @@ -165,6 +170,16 @@ def span(self, span: str) -> List[HistoryItem]: end = results.group('end') if end: end = int(end) + # modify end so it's inclusive of the last element + if end == -1: + # -1 as the end means include the last command in the array, which in pythonic + # terms means to not provide an ending index. If you put -1 as the ending index + # python excludes the last item in the list. + end = None + elif end < -1: + # if the ending is smaller than -1, make it one larger so it includes + # the element (python native indices exclude the last referenced element) + end += 1 if start is not None and end is not None: # we have both start and end, return a slice of history diff --git a/docs/freefeatures.rst b/docs/freefeatures.rst index 4bfeffd03..001a75991 100644 --- a/docs/freefeatures.rst +++ b/docs/freefeatures.rst @@ -293,9 +293,10 @@ entered, and so forth:: 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:: +between, and including, those two numbers:: - (Cmd) history 2:3 + (Cmd) history 1:3 + 1 alias create one !echo one 2 alias create two !echo two 3 alias create three !echo three diff --git a/tests/test_history.py b/tests/test_history.py index d9918b7a2..9046e8777 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -77,14 +77,14 @@ def test_history_class_span(hist): assert hist.span('1..3') == ['first', 'second', 'third'] assert hist.span('1:3') == ['first', 'second', 'third'] - assert hist.span('2:-1') == ['second', 'third'] + assert hist.span('2:-1') == ['second', 'third', 'fourth'] assert hist.span('-3:4') == ['second', 'third','fourth'] - assert hist.span('-4:-2') == ['first', 'second'] + assert hist.span('-4:-2') == ['first', 'second', 'third'] - assert hist.span(':-2') == ['first', 'second'] - assert hist.span('..-2') == ['first', 'second'] + assert hist.span(':-2') == ['first', 'second', 'third'] + assert hist.span('..-2') == ['first', 'second', 'third'] - value_errors = ['fred', 'fred:joe', 'a..b', '2 ..', '1 : 3'] + value_errors = ['fred', 'fred:joe', 'a..b', '2 ..', '1 : 3', '1:0', '0:3'] for tryit in value_errors: with pytest.raises(ValueError): hist.span(tryit)