summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd2/cmd2.py21
-rw-r--r--cmd2/history.py220
-rw-r--r--docs/freefeatures.rst200
-rw-r--r--tests/test_history.py92
-rw-r--r--tests/test_transcript.py4
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))