summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKevin Van Brunt <kmvanbrunt@gmail.com>2019-06-06 16:00:10 -0400
committerKevin Van Brunt <kmvanbrunt@gmail.com>2019-06-06 16:00:10 -0400
commita490975a030cf7a366e6436b141a7c3ff3b9b018 (patch)
tree522ba401fbec1960181222a5ec3a9aa503be3ba2
parentc4fd5b6403651ccc89976edd4e04549471b4a23b (diff)
parent432427b46428a2b2234eab7b33bb89597f2f7957 (diff)
downloadcmd2-git-a490975a030cf7a366e6436b141a7c3ff3b9b018.tar.gz
Merge branch 'master' into script_refactor
-rw-r--r--.gitignore2
-rw-r--r--CHANGELOG.md43
-rw-r--r--cmd2/cmd2.py166
-rw-r--r--cmd2/history.py109
-rw-r--r--docs/freefeatures.rst2
-rwxr-xr-xexamples/hello_cmd2.py2
-rw-r--r--tests/conftest.py4
-rw-r--r--tests/test_history.py468
8 files changed, 560 insertions, 236 deletions
diff --git a/.gitignore b/.gitignore
index dca35c23..08bb4a40 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,4 +28,4 @@ dmypy.json
dmypy.sock
# cmd2 history file used in hello_cmd2.py
-cmd2_history.txt
+cmd2_history.dat
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d17d442f..cfd0c78c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,15 +23,26 @@
* Added support for custom Namespaces in the argparse decorators. See description of `ns_provider` argument
for more information.
* Transcript testing now sets the `exit_code` returned from `cmdloop` based on Success/Failure
+ * The history of entered commands previously was saved using the readline persistence mechanism,
+ and only persisted if you had readline installed. Now history is persisted independent of readline; user
+ input from previous invocations of `cmd2` based apps now shows in the `history` command.
* Text scripts now run immediately instead of adding their commands to `cmdqueue`. This allows easy capture of
- the entire script's output.
-* Potentially breaking changes
+ the entire script's output.
+* Breaking changes
* Replaced `unquote_redirection_tokens()` with `unquote_specific_tokens()`. This was to support the fix
that allows terminators in alias and macro values.
* Changed `Statement.pipe_to` to a string instead of a list
* `preserve_quotes` is now a keyword-only argument in the argparse decorators
* Refactored so that `cmd2.Cmd.cmdloop()` returns the `exit_code` instead of a call to `sys.exit()`
- * It is now applicaiton developer's responsibility to treat the return value from `cmdloop()` accordingly
+ It is now application developer's responsibility to treat the return value from `cmdloop()` accordingly
+ * Only valid commands are persistent in history between invocations of `cmd2` based apps. Previously
+ all user input was persistent in history. If readline is installed, the history available with the up and
+ down arrow keys (readline history) may not match that shown in the `history` command, because `history`
+ only tracks valid input, while readline history captures all input.
+ * History is now persisted in a binary format, not plain text format. Previous history files are destroyed
+ on first launch of a `cmd2` based app of version 0.9.13 or higher.
+ * HistoryItem class is no longer a subclass of `str`. If you are directly accessing the `.history` attribute
+ of a `cmd2` based app, you will need to update your code to use `.history.get(1).statement.raw` instead.
* Removed internally used `eos` command that was used to keep track of when a text script's commands ended
* Removed cmd2 member called _STOP_AND_EXIT since it was just a boolean value that should always be True
* **Python 3.4 EOL notice**
@@ -42,7 +53,7 @@
* Bug Fixes
* Fixed a bug in how redirection and piping worked inside ``py`` or ``pyscript`` commands
* Fixed bug in `async_alert` where it didn't account for prompts that contained newline characters
- * Fixed path completion case when CWD is just a slash. Relative path matches were incorrectly prepended with a slash.
+ * Fixed path completion case when CWD is just a slash. Relative path matches were incorrectly prepended with a slash.
* Enhancements
* Added ability to include command name placeholders in the message printed when trying to run a disabled command.
* See docstring for ``disable_command()`` or ``disable_category()`` for more details.
@@ -64,7 +75,7 @@
* ``_report_disabled_command_usage()`` - in all cases since this is called when a disabled command is run
* Removed *** from beginning of error messages printed by `do_help()` and `default()`
* Significantly refactored ``cmd.Cmd`` class so that all class attributes got converted to instance attributes, also:
- * Added ``allow_redirection``, ``terminators``, ``multiline_commands``, and ``shortcuts`` as optional arguments
+ * Added ``allow_redirection``, ``terminators``, ``multiline_commands``, and ``shortcuts`` as optional arguments
to ``cmd.Cmd.__init__()`
* A few instance attributes were moved inside ``StatementParser`` and properties were created for accessing them
* ``self.pipe_proc`` is now called ``self.cur_pipe_proc_reader`` and is a ``ProcReader`` class.
@@ -102,7 +113,7 @@
``cmd2`` convention of setting ``self.matches_sorted`` to True before returning the results if you have already
sorted the ``CompletionItem`` list. Otherwise it will be sorted using ``self.matches_sort_key``.
* Removed support for bash completion since this feature had slow performance. Also it relied on
- ``AutoCompleter`` which has since developed a dependency on ``cmd2`` methods.
+ ``AutoCompleter`` which has since developed a dependency on ``cmd2`` methods.
* Removed ability to call commands in ``pyscript`` as if they were functions (e.g. ``app.help()``) in favor
of only supporting one ``pyscript`` interface. This simplifies future maintenance.
* No longer supporting C-style comments. Hash (#) is the only valid comment marker.
@@ -122,7 +133,7 @@
* Fixed bug where the ``set`` command was not tab completing from the current ``settable`` dictionary.
* Enhancements
* Changed edit command to use do_shell() instead of calling os.system()
-
+
## 0.9.8 (February 06, 2019)
* Bug Fixes
* Fixed issue with echoing strings in StdSim. Because they were being sent to a binary buffer, line buffering
@@ -143,9 +154,9 @@
* Deletions (potentially breaking changes)
* Deleted ``Cmd.colorize()`` and ``Cmd._colorcodes`` which were deprecated in 0.9.5
* Replaced ``dir_exe_only`` and ``dir_only`` flags in ``path_complete`` with optional ``path_filter`` function
- that is used to filter paths out of completion results.
+ that is used to filter paths out of completion results.
* ``perror()`` no longer prepends "ERROR: " to the error message being printed
-
+
## 0.9.6 (October 13, 2018)
* Bug Fixes
* Fixed bug introduced in 0.9.5 caused by backing up and restoring `self.prompt` in `pseudo_raw_input`.
@@ -171,8 +182,8 @@
the argparse object. Also, single-character tokens that happen to be a
prefix char are not treated as flags by argparse and AutoCompleter now
matches that behavior.
- * Fixed bug where AutoCompleter was not distinguishing between a negative number and a flag
- * Fixed bug where AutoCompleter did not handle -- the same way argparse does (all args after -- are non-options)
+ * Fixed bug where AutoCompleter was not distinguishing between a negative number and a flag
+ * Fixed bug where AutoCompleter did not handle -- the same way argparse does (all args after -- are non-options)
* Enhancements
* Added ``exit_code`` attribute of ``cmd2.Cmd`` class
* Enables applications to return a non-zero exit code when exiting from ``cmdloop``
@@ -184,10 +195,10 @@
* These allow you to provide feedback to the user in an asychronous fashion, meaning alerts can
display when the user is still entering text at the prompt. See [async_printing.py](https://github.com/python-cmd2/cmd2/blob/master/examples/async_printing.py)
for an example.
- * Cross-platform colored output support
+ * Cross-platform colored output support
* ``colorama`` gets initialized properly in ``Cmd.__init()``
* The ``Cmd.colors`` setting is no longer platform dependent and now has three values:
- * Terminal (default) - output methods do not strip any ANSI escape sequences when output is a terminal, but
+ * Terminal (default) - output methods do not strip any ANSI escape sequences when output is a terminal, but
if the output is a pipe or a file the escape sequences are stripped
* Always - output methods **never** strip ANSI escape sequences, regardless of the output destination
* Never - output methods strip all ANSI escape sequences
@@ -197,18 +208,18 @@
* Deprecations
* Deprecated the built-in ``cmd2`` support for colors including ``Cmd.colorize()`` and ``Cmd._colorcodes``
* Deletions (potentially breaking changes)
- * The ``preparse``, ``postparsing_precmd``, and ``postparsing_postcmd`` methods *deprecated* in the previous release
+ * The ``preparse``, ``postparsing_precmd``, and ``postparsing_postcmd`` methods *deprecated* in the previous release
have been deleted
* The new application lifecycle hook system allows for registration of callbacks to be called at various points
in the lifecycle and is more powerful and flexible than the previous system
* ``alias`` is now a command with sub-commands to create, list, and delete aliases. Therefore its syntax
has changed. All current alias commands in startup scripts or transcripts will break with this release.
* `unalias` was deleted since ``alias delete`` replaced it
-
+
## 0.9.4 (August 21, 2018)
* Bug Fixes
* Fixed bug where ``preparse`` was not getting called
- * Fixed bug in parsing of multiline commands where matching quote is on another line
+ * Fixed bug in parsing of multiline commands where matching quote is on another line
* Enhancements
* Improved implementation of lifecycle hooks to support a plugin
framework, see ``docs/hooks.rst`` for details.
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index 89ea0c0f..50dbcd79 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -34,6 +34,7 @@ import cmd
import glob
import inspect
import os
+import pickle
import re
import sys
import threading
@@ -344,8 +345,8 @@ class Cmd(cmd.Cmd):
:param completekey: (optional) readline name of a completion key, default to Tab
:param stdin: (optional) alternate input file object, if not specified, sys.stdin is used
:param stdout: (optional) alternate output file object, if not specified, sys.stdout is used
- :param persistent_history_file: (optional) file path to load a persistent readline history from
- :param persistent_history_length: (optional) max number of lines which will be written to the history file
+ :param persistent_history_file: (optional) file path to load a persistent cmd2 command history from
+ :param persistent_history_length: (optional) max number of history items to write to the persistent history file
:param startup_script: (optional) file path to a a script to load and execute at startup
:param use_ipython: (optional) should the "ipy" command be included for an embedded IPython shell
:param transcript_files: (optional) allows running transcript tests when allow_cli_args is False
@@ -402,13 +403,15 @@ class Cmd(cmd.Cmd):
self.hidden_commands = ['eof', '_relative_load']
# Commands to exclude from the history command
+ # initialize history
+ self.persistent_history_length = persistent_history_length
+ self._initialize_history(persistent_history_file)
self.exclude_from_history = '''history edit eof'''.split()
# Command aliases and macros
self.macros = dict()
self.initial_stdout = sys.stdout
- self.history = History()
self.pystate = {}
self.py_history = []
self.pyscript_name = 'app'
@@ -460,41 +463,9 @@ class Cmd(cmd.Cmd):
# If this string is non-empty, then this warning message will print if a broken pipe error occurs while printing
self.broken_pipe_warning = ''
- # Check if history should persist
- self.persistent_history_file = ''
- if persistent_history_file and rl_type != RlType.NONE:
- persistent_history_file = os.path.expanduser(persistent_history_file)
- read_err = False
-
- try:
- # First try to read any existing history file
- readline.read_history_file(persistent_history_file)
- except FileNotFoundError:
- pass
- except OSError as ex:
- self.perror("readline cannot read persistent history file '{}': {}".format(persistent_history_file, ex),
- traceback_war=False)
- read_err = True
-
- if not read_err:
- try:
- # Make sure readline is able to write the history file. Doing it this way is a more thorough check
- # than trying to open the file with write access since readline's underlying function needs to
- # create a temporary file in the same directory and may not have permission.
- readline.set_history_length(persistent_history_length)
- readline.write_history_file(persistent_history_file)
- except OSError as ex:
- self.perror("readline cannot write persistent history file '{}': {}".
- format(persistent_history_file, ex), traceback_war=False)
- else:
- # Set history file and register to save our history at exit
- import atexit
- self.persistent_history_file = persistent_history_file
- atexit.register(readline.write_history_file, self.persistent_history_file)
-
# If a startup script is provided, then add it in the queue to load
if startup_script is not None:
- startup_script = os.path.expanduser(startup_script)
+ startup_script = os.path.abspath(os.path.expanduser(startup_script))
if os.path.exists(startup_script) and os.path.getsize(startup_script) > 0:
self.cmdqueue.append("load '{}'".format(startup_script))
@@ -2382,8 +2353,8 @@ class Cmd(cmd.Cmd):
" If you want to use redirection, pipes, or terminators like ';' in the value\n"
" of the alias, then quote them.\n"
"\n"
- " Since aliases are resolved during parsing, tab completion will function as it\n"
- " would for the actual command the alias resolves to.\n"
+ " Since aliases are resolved during parsing, tab completion will function as\n"
+ " it would for the actual command the alias resolves to.\n"
"\n"
"Examples:\n"
" alias create ls !ls -lF\n"
@@ -2413,8 +2384,8 @@ class Cmd(cmd.Cmd):
# alias -> list
alias_list_help = "list aliases"
- alias_list_description = ("List specified aliases in a reusable form that can be saved to a startup script\n"
- "to preserve aliases across sessions\n"
+ alias_list_description = ("List specified aliases in a reusable form that can be saved to a startup\n"
+ "script to preserve aliases across sessions\n"
"\n"
"Without arguments, all aliases will be listed.")
@@ -2565,17 +2536,17 @@ class Cmd(cmd.Cmd):
"\n"
"The following creates a macro called my_macro that expects two arguments:\n"
"\n"
- " macro create my_macro make_dinner -meat {1} -veggie {2}\n"
+ " macro create my_macro make_dinner --meat {1} --veggie {2}\n"
"\n"
- "When the macro is called, the provided arguments are resolved and the assembled\n"
- "command is run. For example:\n"
+ "When the macro is called, the provided arguments are resolved and the\n"
+ "assembled command is run. For example:\n"
"\n"
- " my_macro beef broccoli ---> make_dinner -meat beef -veggie broccoli\n"
+ " my_macro beef broccoli ---> make_dinner --meat beef --veggie broccoli\n"
"\n"
"Notes:\n"
" To use the literal string {1} in your command, escape it this way: {{1}}.\n"
"\n"
- " Extra arguments passed when calling a macro are tacked onto resolved command.\n"
+ " Extra arguments passed to a macro are appended to resolved command.\n"
"\n"
" An argument number can be repeated in a macro. In the following example the\n"
" first argument will populate both {1} instances.\n"
@@ -3062,8 +3033,8 @@ class Cmd(cmd.Cmd):
"has limited ability to parse Python statements into tokens. In particular,\n"
"there may be problems with whitespace and quotes depending on their placement.\n"
"\n"
- "If you see strange parsing behavior, it's best to just open the Python shell by\n"
- "providing no arguments to py and run more complex statements there.")
+ "If you see strange parsing behavior, it's best to just open the Python shell\n"
+ "by providing no arguments to py and run more complex statements there.")
py_parser = ACArgumentParser(description=py_description)
py_parser.add_argument('command', help="command to run", nargs='?')
@@ -3333,6 +3304,9 @@ class Cmd(cmd.Cmd):
history_format_group.add_argument('-v', '--verbose', action='store_true',
help='display history and include expanded commands if they\n'
'differ from the typed command')
+ history_format_group.add_argument('-a', '--all', action='store_true',
+ help='display all commands, including ones persisted from\n'
+ 'previous sessions')
history_arg_help = ("empty all history items\n"
"a one history item by number\n"
@@ -3367,10 +3341,11 @@ class Cmd(cmd.Cmd):
# Clear command and readline history
self.history.clear()
+ if self.persistent_history_file:
+ os.remove(self.persistent_history_file)
+
if rl_type != RlType.NONE:
readline.clear_history()
- if self.persistent_history_file:
- os.remove(self.persistent_history_file)
return
# If an argument was supplied, then retrieve partial contents of the history
@@ -3388,18 +3363,18 @@ class Cmd(cmd.Cmd):
if '..' in arg or ':' in arg:
# Get a slice of history
- history = self.history.span(arg)
+ history = self.history.span(arg, args.all)
elif arg_is_int:
history = [self.history.get(arg)]
elif arg.startswith(r'/') and arg.endswith(r'/'):
- history = self.history.regex_search(arg)
+ history = self.history.regex_search(arg, args.all)
else:
- history = self.history.str_search(arg)
+ history = self.history.str_search(arg, args.all)
else:
# If no arg given, then retrieve the entire history
cowardly_refuse_to_run = True
# Get a copy of the history so it doesn't get mutated while we are using it
- history = self.history[:]
+ history = self.history.span(':', args.all)
if args.run:
if cowardly_refuse_to_run:
@@ -3416,7 +3391,7 @@ class Cmd(cmd.Cmd):
if command.statement.multiline_command:
fobj.write('{}\n'.format(command.expanded.rstrip()))
else:
- fobj.write('{}\n'.format(command))
+ fobj.write('{}\n'.format(command.raw))
try:
self.do_edit(fname)
return self.do_load(fname)
@@ -3425,11 +3400,11 @@ class Cmd(cmd.Cmd):
elif args.output_file:
try:
with open(os.path.expanduser(args.output_file), 'w') as fobj:
- for command in history:
- if command.statement.multiline_command:
- fobj.write('{}\n'.format(command.expanded.rstrip()))
+ for item in history:
+ if item.statement.multiline_command:
+ fobj.write('{}\n'.format(item.expanded.rstrip()))
else:
- fobj.write('{}\n'.format(command))
+ fobj.write('{}\n'.format(item.raw))
plural = 's' if len(history) > 1 else ''
self.pfeedback('{} command{} saved to {}'.format(len(history), plural, args.output_file))
except Exception as e:
@@ -3441,6 +3416,79 @@ class Cmd(cmd.Cmd):
for hi in history:
self.poutput(hi.pr(script=args.script, expanded=args.expanded, verbose=args.verbose))
+ def _initialize_history(self, hist_file):
+ """Initialize history using history related attributes
+
+ This function can determine whether history is saved in the prior text-based
+ format (one line of input is stored as one line in the file), or the new-as-
+ of-version 0.9.13 pickle based format.
+
+ History created by versions <= 0.9.12 is in readline format, i.e. plain text files.
+
+ Initializing history does not effect history files on disk, versions >= 0.9.13 always
+ write history in the pickle format.
+ """
+ self.history = History()
+ # with no persistent history, nothing else in this method is relevant
+ if not hist_file:
+ self.persistent_history_file = hist_file
+ return
+
+ hist_file = os.path.abspath(os.path.expanduser(hist_file))
+
+ # first we try and unpickle the history file
+ history = History()
+ # on Windows, trying to open a directory throws a permission
+ # error, not a `IsADirectoryError`. So we'll check it ourselves.
+ if os.path.isdir(hist_file):
+ msg = "persistent history file '{}' is a directory"
+ self.perror(msg.format(hist_file))
+ return
+
+ try:
+ with open(hist_file, 'rb') as fobj:
+ history = pickle.load(fobj)
+ except (AttributeError, EOFError, FileNotFoundError, ImportError, IndexError, KeyError, pickle.UnpicklingError):
+ # If any non-operating system error occurs when attempting to unpickle, just use an empty history
+ pass
+ except OSError as ex:
+ msg = "can not read persistent history file '{}': {}"
+ self.perror(msg.format(hist_file, ex), traceback_war=False)
+ return
+
+ self.history = history
+ self.history.start_session()
+ self.persistent_history_file = hist_file
+
+ # populate readline history
+ if rl_type != RlType.NONE:
+ last = None
+ for item in history:
+ # readline only adds a single entry for multiple sequential identical commands
+ # so we emulate that behavior here
+ if item.raw != last:
+ readline.add_history(item.raw)
+ last = item.raw
+
+ # register a function to write history at save
+ # if the history file is in plain text format from 0.9.12 or lower
+ # this will fail, and the history in the plain text file will be lost
+ import atexit
+ atexit.register(self._persist_history)
+
+ def _persist_history(self):
+ """write history out to the history file"""
+ if not self.persistent_history_file:
+ return
+
+ self.history.truncate(self.persistent_history_length)
+ try:
+ with open(self.persistent_history_file, 'wb') as fobj:
+ pickle.dump(self.history, fobj)
+ except OSError as ex:
+ msg = "can not write persistent history file '{}': {}"
+ self.perror(msg.format(self.persistent_history_file, ex), traceback_war=False)
+
def _generate_transcript(self, history: List[Union[HistoryItem, str]], transcript_file: str) -> Optional[bool]:
"""
Generate a transcript file from a given history of commands
@@ -3479,6 +3527,8 @@ class Cmd(cmd.Cmd):
# the command from the output
first = True
command = ''
+ if isinstance(history_item, HistoryItem):
+ history_item = history_item.raw
for line in history_item.splitlines():
if first:
command += '{}{}\n'.format(self.prompt, line)
diff --git a/cmd2/history.py b/cmd2/history.py
index 7cc36bfc..bbeb9199 100644
--- a/cmd2/history.py
+++ b/cmd2/history.py
@@ -7,25 +7,29 @@ import re
from typing import List, Union
+import attr
+
from . import utils
from .parsing import Statement
-class HistoryItem(str):
+@attr.s(frozen=True)
+class HistoryItem():
"""Class used to represent one command in the History list"""
- listformat = ' {:>4} {}\n'
- ex_listformat = ' {:>4}x {}\n'
+ _listformat = ' {:>4} {}\n'
+ _ex_listformat = ' {:>4}x {}\n'
- def __new__(cls, statement: Statement):
- """Create a new instance of HistoryItem
+ statement = attr.ib(default=None, validator=attr.validators.instance_of(Statement))
+ idx = attr.ib(default=None, validator=attr.validators.instance_of(int))
- We must override __new__ because we are subclassing `str` which is
- immutable and takes a different number of arguments as Statement.
- """
- hi = super().__new__(cls, statement.raw)
- hi.statement = statement
- hi.idx = None
- return hi
+ def __str__(self):
+ """A convenient human readable representation of the history item"""
+ return self.statement.raw
+
+ @property
+ def raw(self) -> str:
+ """Return the raw input from the user for this item"""
+ return self.statement.raw
@property
def expanded(self) -> str:
@@ -40,22 +44,22 @@ class HistoryItem(str):
:return: pretty print string version of a HistoryItem
"""
if verbose:
- ret_str = self.listformat.format(self.idx, str(self).rstrip())
- if self != self.expanded:
- ret_str += self.ex_listformat.format(self.idx, self.expanded.rstrip())
+ ret_str = self._listformat.format(self.idx, self.raw)
+ if self.raw != self.expanded.rstrip():
+ ret_str += self._ex_listformat.format(self.idx, self.expanded)
else:
if script:
# display without entry numbers
if expanded or self.statement.multiline_command:
ret_str = self.expanded.rstrip()
else:
- ret_str = str(self)
+ ret_str = self.raw.rstrip()
else:
# display a numbered list
if expanded or self.statement.multiline_command:
- ret_str = self.listformat.format(self.idx, self.expanded.rstrip())
+ ret_str = self._listformat.format(self.idx, self.expanded.rstrip())
else:
- ret_str = self.listformat.format(self.idx, str(self).rstrip())
+ ret_str = self._listformat.format(self.idx, self.raw.rstrip())
return ret_str
@@ -69,8 +73,14 @@ class History(list):
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
-
"""
+ def __init__(self, seq=()) -> None:
+ super().__init__(seq)
+ self.session_start_index = 0
+
+ def start_session(self) -> None:
+ """Start a new session, thereby setting the next index as the first index in the new session."""
+ self.session_start_index = len(self)
# noinspection PyMethodMayBeStatic
def _zero_based_index(self, onebased: Union[int, str]) -> int:
@@ -81,13 +91,17 @@ class History(list):
return result
def append(self, new: Statement) -> None:
- """Append a HistoryItem to end of the History list
+ """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)
+ history_item = HistoryItem(new, len(self) + 1)
+ super().append(history_item)
+
+ def clear(self) -> None:
+ """Remove all items from the History list."""
+ super().clear()
+ self.start_session()
def get(self, index: Union[int, str]) -> HistoryItem:
"""Get item from the History list using 1-based indexing.
@@ -130,10 +144,11 @@ class History(list):
#
spanpattern = re.compile(r'^\s*(?P<start>-?[1-9]\d*)?(?P<separator>:|(\.{2,}))?(?P<end>-?[1-9]\d*)?\s*$')
- def span(self, span: str) -> List[HistoryItem]:
+ def span(self, span: str, include_persisted: bool = False) -> List[HistoryItem]:
"""Return an index or slice of the History list,
:param span: string containing an index or a slice
+ :param include_persisted: (optional) if True, then retrieve full results including from persisted history
:return: a list of HistoryItems
This method can accommodate input in any of these forms:
@@ -188,31 +203,43 @@ class History(list):
# take a slice of the array
result = self[start:]
elif end is not None and sep is not None:
- result = self[:end]
+ if include_persisted:
+ result = self[:end]
+ else:
+ result = self[self.session_start_index:end]
elif start is not None:
- # there was no separator so it's either a posative or negative integer
+ # there was no separator so it's either a positive or negative integer
result = [self[start]]
else:
# we just have a separator, return the whole list
- result = self[:]
+ if include_persisted:
+ result = self[:]
+ else:
+ result = self[self.session_start_index:]
return result
- def str_search(self, search: str) -> List[HistoryItem]:
+ def str_search(self, search: str, include_persisted: bool = False) -> List[HistoryItem]:
"""Find history items which contain a given string
:param search: the string to search for
+ :param include_persisted: (optional) if True, then search full history including persisted history
:return: a list of history items, or an empty list if the string was not found
"""
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)]
+ inraw = sloppy in utils.norm_fold(history_item.raw)
+ inexpanded = sloppy in utils.norm_fold(history_item.expanded)
+ return inraw or inexpanded
- def regex_search(self, regex: str) -> List[HistoryItem]:
+ search_list = self if include_persisted else self[self.session_start_index:]
+ return [item for item in search_list if isin(item)]
+
+ def regex_search(self, regex: str, include_persisted: bool = False) -> List[HistoryItem]:
"""Find history items which match a given regular expression
:param regex: the regular expression to search for.
+ :param include_persisted: (optional) if True, then search full history including persisted history
:return: a list of history items, or an empty list if the string was not found
"""
regex = regex.strip()
@@ -222,5 +249,21 @@ class History(list):
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)]
+ return finder.search(hi.raw) or finder.search(hi.expanded)
+
+ search_list = self if include_persisted else self[self.session_start_index:]
+ return [itm for itm in search_list if isin(itm)]
+
+ def truncate(self, max_length: int) -> None:
+ """Truncate the length of the history, dropping the oldest items if necessary
+
+ :param max_length: the maximum length of the history, if negative, all history
+ items will be deleted
+ :return: nothing
+ """
+ if max_length <= 0:
+ # remove all history
+ del self[:]
+ elif len(self) > max_length:
+ last_element = len(self) - max_length
+ del self[0:last_element]
diff --git a/docs/freefeatures.rst b/docs/freefeatures.rst
index 11b5de68..05b5391d 100644
--- a/docs/freefeatures.rst
+++ b/docs/freefeatures.rst
@@ -258,7 +258,7 @@ All cmd_-based applications on systems with the ``readline`` module
also provide `Readline Emacs editing mode`_. With this you can, for example, use **Ctrl-r** to search backward through
the readline history.
-``cmd2`` adds the option of making this readline history persistent via optional arguments to ``cmd2.Cmd.__init__()``:
+``cmd2`` adds the option of making this history persistent via optional arguments to ``cmd2.Cmd.__init__()``:
.. automethod:: cmd2.cmd2.Cmd.__init__
diff --git a/examples/hello_cmd2.py b/examples/hello_cmd2.py
index 395663f2..84754623 100755
--- a/examples/hello_cmd2.py
+++ b/examples/hello_cmd2.py
@@ -11,7 +11,7 @@ if __name__ == '__main__':
# Set "use_ipython" to True to include the ipy command if IPython is installed, which supports advanced interactive
# debugging of your application via introspection on self.
- app = cmd2.Cmd(use_ipython=True, persistent_history_file='cmd2_history.txt')
+ app = cmd2.Cmd(use_ipython=True, persistent_history_file='cmd2_history.dat')
app.locals_in_py = True # Enable access to "self" within the py command
app.debug = True # Show traceback if/when an exception occurs
sys.exit(app.cmdloop())
diff --git a/tests/conftest.py b/tests/conftest.py
index 9d55eb4d..dc5c1ab1 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -59,6 +59,7 @@ shortcuts List available shortcuts
# Help text for the history command
HELP_HISTORY = """Usage: history [-h] [-r | -e | -o FILE | -t TRANSCRIPT | -c] [-s] [-x] [-v]
+ [-a]
[arg]
View, run, edit, save, or clear previously entered commands
@@ -88,7 +89,8 @@ formatting:
macros expanded, instead of typed commands
-v, --verbose display history and include expanded commands if they
differ from the typed command
-
+ -a, --all display all commands, including ones persisted from
+ previous sessions
"""
# Output from the shortcuts command with default built-in shortcuts
diff --git a/tests/test_history.py b/tests/test_history.py
index 105dead9..5e01688c 100644
--- a/tests/test_history.py
+++ b/tests/test_history.py
@@ -5,11 +5,11 @@ Test history functions of cmd2
"""
import tempfile
import os
-import sys
import pytest
-# Python 3.5 had some regressions in the unitest.mock module, so use 3rd party mock if available
+# Python 3.5 had some regressions in the unitest.mock module, so use
+# 3rd party mock if available
try:
import mock
except ImportError:
@@ -19,79 +19,180 @@ import cmd2
from .conftest import run_cmd, normalize, HELP_HISTORY
-def test_base_help_history(base_app):
- out, err = run_cmd(base_app, 'help history')
- assert out == normalize(HELP_HISTORY)
-
-def test_exclude_from_history(base_app, monkeypatch):
- # Set a fake editor just to make sure we have one. We aren't really going to call it due to the mock
- base_app.editor = 'fooedit'
-
- # Mock out the subprocess.Popen call so we don't actually open an editor
- m = mock.MagicMock(name='Popen')
- monkeypatch.setattr("subprocess.Popen", m)
-
- # Run edit command
- run_cmd(base_app, 'edit')
-
- # Run history command
- run_cmd(base_app, 'history')
-
- # Verify that the history is empty
- out, err = run_cmd(base_app, 'history')
- assert out == []
-
- # Now run a command which isn't excluded from the history
- run_cmd(base_app, 'help')
-
- # And verify we have a history now ...
- out, err = run_cmd(base_app, 'history')
- expected = normalize(""" 1 help""")
- assert out == expected
-
+#
+# readline tests
+#
+def test_readline_remove_history_item(base_app):
+ from cmd2.rl_utils import readline
+ assert readline.get_current_history_length() == 0
+ readline.add_history('this is a test')
+ assert readline.get_current_history_length() == 1
+ readline.remove_history_item(0)
+ assert readline.get_current_history_length() == 0
+#
+# test History() class
+#
@pytest.fixture
def hist():
from cmd2.parsing import Statement
from cmd2.cmd2 import History, HistoryItem
- h = History([HistoryItem(Statement('', raw='first')),
- HistoryItem(Statement('', raw='second')),
- HistoryItem(Statement('', raw='third')),
- HistoryItem(Statement('', raw='fourth'))])
+ h = History([HistoryItem(Statement('', raw='first'), 1),
+ HistoryItem(Statement('', raw='second'), 2),
+ HistoryItem(Statement('', raw='third'), 3),
+ HistoryItem(Statement('', raw='fourth'),4)])
+ return h
+
+@pytest.fixture
+def persisted_hist():
+ from cmd2.parsing import Statement
+ from cmd2.cmd2 import History, HistoryItem
+ h = History([HistoryItem(Statement('', raw='first'), 1),
+ HistoryItem(Statement('', raw='second'), 2),
+ HistoryItem(Statement('', raw='third'), 3),
+ HistoryItem(Statement('', raw='fourth'),4)])
+ h.start_session()
+ h.append(Statement('', raw='fifth'))
+ h.append(Statement('', raw='sixth'))
return h
def test_history_class_span(hist):
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('3')[0].statement.raw == 'third'
+ assert hist.span('-1')[0].statement.raw == 'fourth'
+
+ span = hist.span('2..')
+ assert len(span) == 3
+ assert span[0].statement.raw == 'second'
+ assert span[1].statement.raw == 'third'
+ assert span[2].statement.raw == 'fourth'
+
+ span = hist.span('2:')
+ assert len(span) == 3
+ assert span[0].statement.raw == 'second'
+ assert span[1].statement.raw == 'third'
+ assert span[2].statement.raw == 'fourth'
+
+ span = hist.span('-2..')
+ assert len(span) == 2
+ assert span[0].statement.raw == 'third'
+ assert span[1].statement.raw == 'fourth'
+
+ span = hist.span('-2:')
+ assert len(span) == 2
+ assert span[0].statement.raw == 'third'
+ assert span[1].statement.raw == 'fourth'
+
+ span = hist.span('1..3')
+ assert len(span) == 3
+ assert span[0].statement.raw == 'first'
+ assert span[1].statement.raw == 'second'
+ assert span[2].statement.raw == 'third'
+
+ span = hist.span('1:3')
+ assert len(span) == 3
+ assert span[0].statement.raw == 'first'
+ assert span[1].statement.raw == 'second'
+ assert span[2].statement.raw == 'third'
+
+ span = hist.span('2:-1')
+ assert len(span) == 3
+ assert span[0].statement.raw == 'second'
+ assert span[1].statement.raw == 'third'
+ assert span[2].statement.raw == 'fourth'
+
+ span = hist.span('-3:4')
+ assert len(span) == 3
+ assert span[0].statement.raw == 'second'
+ assert span[1].statement.raw == 'third'
+ assert span[2].statement.raw == 'fourth'
+
+ span = hist.span('-4:-2')
+ assert len(span) == 3
+ assert span[0].statement.raw == 'first'
+ assert span[1].statement.raw == 'second'
+ assert span[2].statement.raw == 'third'
+
+ span = hist.span(':-2')
+ assert len(span) == 3
+ assert span[0].statement.raw == 'first'
+ assert span[1].statement.raw == 'second'
+ assert span[2].statement.raw == 'third'
+
+ span = hist.span('..-2')
+ assert len(span) == 3
+ assert span[0].statement.raw == 'first'
+ assert span[1].statement.raw == 'second'
+ assert span[2].statement.raw == 'third'
- assert hist.span('1..3') == ['first', 'second', 'third']
- assert hist.span('1:3') == ['first', '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', 'third']
+ 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)
- assert hist.span(':-2') == ['first', 'second', 'third']
- assert hist.span('..-2') == ['first', 'second', 'third']
+def test_persisted_history_span(persisted_hist):
+ for tryit in ['*', ':', '-', 'all', 'ALL']:
+ assert persisted_hist.span(tryit, include_persisted=True) == persisted_hist
+ assert persisted_hist.span(tryit, include_persisted=False) != persisted_hist
+
+ assert persisted_hist.span('3')[0].statement.raw == 'third'
+ assert persisted_hist.span('-1')[0].statement.raw == 'sixth'
+
+ span = persisted_hist.span('2..')
+ assert len(span) == 5
+ assert span[0].statement.raw == 'second'
+ assert span[1].statement.raw == 'third'
+ assert span[2].statement.raw == 'fourth'
+ assert span[3].statement.raw == 'fifth'
+ assert span[4].statement.raw == 'sixth'
+
+ span = persisted_hist.span('-2..')
+ assert len(span) == 2
+ assert span[0].statement.raw == 'fifth'
+ assert span[1].statement.raw == 'sixth'
+
+ span = persisted_hist.span('1..3')
+ assert len(span) == 3
+ assert span[0].statement.raw == 'first'
+ assert span[1].statement.raw == 'second'
+ assert span[2].statement.raw == 'third'
+
+ span = persisted_hist.span('2:-1')
+ assert len(span) == 5
+ assert span[0].statement.raw == 'second'
+ assert span[1].statement.raw == 'third'
+ assert span[2].statement.raw == 'fourth'
+ assert span[3].statement.raw == 'fifth'
+ assert span[4].statement.raw == 'sixth'
+
+ span = persisted_hist.span('-3:4')
+ assert len(span) == 1
+ assert span[0].statement.raw == 'fourth'
+
+ span = persisted_hist.span(':-2', include_persisted=True)
+ assert len(span) == 5
+ assert span[0].statement.raw == 'first'
+ assert span[1].statement.raw == 'second'
+ assert span[2].statement.raw == 'third'
+ assert span[3].statement.raw == 'fourth'
+ assert span[4].statement.raw == 'fifth'
+
+ span = persisted_hist.span(':-2', include_persisted=False)
+ assert len(span) == 1
+ assert span[0].statement.raw == 'fifth'
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)
+ persisted_hist.span(tryit)
def test_history_class_get(hist):
- assert hist.get('1') == 'first'
- assert hist.get(3) == 'third'
+ assert hist.get('1').statement.raw == 'first'
+ assert hist.get(3).statement.raw == 'third'
assert hist.get('-2') == hist[-2]
- assert hist.get(-1) == 'fourth'
+ assert hist.get(-1).statement.raw == 'fourth'
with pytest.raises(IndexError):
hist.get(0)
@@ -114,13 +215,78 @@ def test_history_class_get(hist):
hist.get(None)
def test_history_str_search(hist):
- assert hist.str_search('ir') == ['first', 'third']
- assert hist.str_search('rth') == ['fourth']
+ items = hist.str_search('ir')
+ assert len(items) == 2
+ assert items[0].statement.raw == 'first'
+ assert items[1].statement.raw == 'third'
+
+ items = hist.str_search('rth')
+ assert len(items) == 1
+ assert items[0].statement.raw == 'fourth'
def test_history_regex_search(hist):
- assert hist.regex_search('/i.*d/') == ['third']
- assert hist.regex_search('s[a-z]+ond') == ['second']
+ items = hist.regex_search('/i.*d/')
+ assert len(items) == 1
+ assert items[0].statement.raw == 'third'
+
+ items = hist.regex_search('s[a-z]+ond')
+ assert len(items) == 1
+ assert items[0].statement.raw == 'second'
+
+def test_history_max_length_zero(hist):
+ hist.truncate(0)
+ assert len(hist) == 0
+
+def test_history_max_length_negative(hist):
+ hist.truncate(-1)
+ assert len(hist) == 0
+
+def test_history_max_length(hist):
+ hist.truncate(2)
+ assert len(hist) == 2
+ assert hist.get(1).statement.raw == 'third'
+ assert hist.get(2).statement.raw == 'fourth'
+
+#
+# test HistoryItem()
+#
+@pytest.fixture
+def histitem():
+ from cmd2.parsing import Statement
+ from cmd2.history import HistoryItem
+ statement = Statement('history',
+ raw='help history',
+ command='help',
+ arg_list=['history'],
+ )
+ histitem = HistoryItem(statement, 1)
+ return histitem
+
+def test_history_item_instantiate():
+ from cmd2.parsing import Statement
+ from cmd2.history import HistoryItem
+ statement = Statement('history',
+ raw='help history',
+ command='help',
+ arg_list=['history'],
+ )
+ with pytest.raises(TypeError):
+ _ = HistoryItem()
+ with pytest.raises(TypeError):
+ _ = HistoryItem(idx=1)
+ with pytest.raises(TypeError):
+ _ = HistoryItem(statement=statement)
+ with pytest.raises(TypeError):
+ _ = HistoryItem(statement=statement, idx='hi')
+
+def test_history_item_properties(histitem):
+ assert histitem.raw == 'help history'
+ assert histitem.expanded == 'help history'
+ assert str(histitem) == 'help history'
+#
+# test history command
+#
def test_base_history(base_app):
run_cmd(base_app, 'help')
run_cmd(base_app, 'shortcuts')
@@ -199,7 +365,6 @@ def test_history_with_integer_argument(base_app):
""")
assert out == expected
-
def test_history_with_integer_span(base_app):
run_cmd(base_app, 'help')
run_cmd(base_app, 'shortcuts')
@@ -305,7 +470,8 @@ def test_history_verbose_with_other_options(base_app):
options_to_test = ['-r', '-e', '-o file', '-t file', '-c', '-x']
for opt in options_to_test:
out, err = run_cmd(base_app, 'history -v ' + opt)
- assert len(out) == 3
+ assert len(out) == 4
+ assert out[0] == '-v can not be used with any other options'
assert out[1].startswith('Usage:')
def test_history_verbose(base_app):
@@ -321,7 +487,8 @@ def test_history_script_with_invalid_options(base_app):
options_to_test = ['-r', '-e', '-o file', '-t file', '-c']
for opt in options_to_test:
out, err = run_cmd(base_app, 'history -s ' + opt)
- assert len(out) == 3
+ assert len(out) == 4
+ assert out[0] == '-s and -x can not be used with -c, -r, -e, -o, or -t'
assert out[1].startswith('Usage:')
def test_history_script(base_app):
@@ -336,7 +503,8 @@ def test_history_expanded_with_invalid_options(base_app):
options_to_test = ['-r', '-e', '-o file', '-t file', '-c']
for opt in options_to_test:
out, err = run_cmd(base_app, 'history -x ' + opt)
- assert len(out) == 3
+ assert len(out) == 4
+ assert out[0] == '-s and -x can not be used with -c, -r, -e, -o, or -t'
assert out[1].startswith('Usage:')
def test_history_expanded(base_app):
@@ -357,21 +525,40 @@ def test_history_script_expanded(base_app):
expected = ['alias create s shortcuts', 'shortcuts']
assert out == expected
+def test_base_help_history(base_app):
+ out, err = run_cmd(base_app, 'help history')
+ assert out == normalize(HELP_HISTORY)
-#####
-#
-# readline tests
-#
-#####
-def test_readline_remove_history_item(base_app):
- from cmd2.rl_utils import readline
- assert readline.get_current_history_length() == 0
- readline.add_history('this is a test')
- assert readline.get_current_history_length() == 1
- readline.remove_history_item(0)
- assert readline.get_current_history_length() == 0
+def test_exclude_from_history(base_app, monkeypatch):
+ # Set a fake editor just to make sure we have one. We aren't
+ # really going to call it due to the mock
+ base_app.editor = 'fooedit'
+
+ # Mock out the subprocess.Popen call so we don't actually open an editor
+ m = mock.MagicMock(name='Popen')
+ monkeypatch.setattr("subprocess.Popen", m)
+
+ # Run edit command
+ run_cmd(base_app, 'edit')
+
+ # Run history command
+ run_cmd(base_app, 'history')
+
+ # Verify that the history is empty
+ out, err = run_cmd(base_app, 'history')
+ assert out == []
+
+ # Now run a command which isn't excluded from the history
+ run_cmd(base_app, 'help')
+ # And verify we have a history now ...
+ out, err = run_cmd(base_app, 'history')
+ expected = normalize(""" 1 help""")
+ assert out == expected
+#
+# test history initialization
+#
@pytest.fixture(scope="session")
def hist_file():
fd, filename = tempfile.mkstemp(prefix='hist_file', suffix='.txt')
@@ -383,62 +570,93 @@ def hist_file():
except FileNotFoundError:
pass
-def test_existing_history_file(hist_file, capsys):
- import atexit
- import readline
-
- # Create the history file before making cmd2 app
- with open(hist_file, 'w'):
- pass
-
- # Create a new cmd2 app
- cmd2.Cmd(persistent_history_file=hist_file)
- _, err = capsys.readouterr()
-
- # Make sure there were no errors
- assert err == ''
+def test_history_file_is_directory(capsys):
+ with tempfile.TemporaryDirectory() as test_dir:
+ # Create a new cmd2 app
+ cmd2.Cmd(persistent_history_file=test_dir)
+ _, err = capsys.readouterr()
+ assert 'is a directory' in err
- # Unregister the call to write_history_file that cmd2 did
- atexit.unregister(readline.write_history_file)
+def test_history_file_permission_error(mocker, capsys):
+ mock_open = mocker.patch('builtins.open')
+ mock_open.side_effect = PermissionError
- # Remove created history file
- os.remove(hist_file)
+ cmd2.Cmd(persistent_history_file='/tmp/doesntmatter')
+ out, err = capsys.readouterr()
+ assert not out
+ assert 'can not read' in err
-def test_new_history_file(hist_file, capsys):
- import atexit
- import readline
+def test_history_file_conversion_no_truncate_on_init(hist_file, capsys):
+ # make sure we don't truncate the plain text history file on init
+ # it shouldn't get converted to pickle format until we save history
- # Remove any existing history file
- try:
- os.remove(hist_file)
- except OSError:
- pass
+ # first we need some plain text commands in the history file
+ with open(hist_file, 'w') as hfobj:
+ hfobj.write('help\n')
+ hfobj.write('alias\n')
+ hfobj.write('alias create s shortcuts\n')
# Create a new cmd2 app
cmd2.Cmd(persistent_history_file=hist_file)
- _, err = capsys.readouterr()
-
- # Make sure there were no errors
- assert err == ''
- # Unregister the call to write_history_file that cmd2 did
- atexit.unregister(readline.write_history_file)
-
- # Remove created history file
- os.remove(hist_file)
-
-def test_bad_history_file_path(capsys, request):
- # Use a directory path as the history file
- test_dir = os.path.dirname(request.module.__file__)
-
- # Create a new cmd2 app
- 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.
- assert 'readline cannot write' in err
- else:
- # GNU readline raises an exception upon trying to read the directory as a file
- assert 'readline cannot read' in err
+ # history should be initialized, but the file on disk should
+ # still be plain text
+ with open(hist_file, 'r') as hfobj:
+ histlist= hfobj.readlines()
+
+ assert len(histlist) == 3
+ # history.get() is overridden to be one based, not zero based
+ assert histlist[0]== 'help\n'
+ assert histlist[1] == 'alias\n'
+ assert histlist[2] == 'alias create s shortcuts\n'
+
+def test_history_populates_readline(hist_file):
+ # - create a cmd2 with persistent history
+ app = cmd2.Cmd(persistent_history_file=hist_file)
+ run_cmd(app, 'help')
+ run_cmd(app, 'shortcuts')
+ run_cmd(app, 'shortcuts')
+ run_cmd(app, 'alias')
+ # call the private method which is registered to write history at exit
+ app._persist_history()
+
+ # see if history came back
+ app = cmd2.Cmd(persistent_history_file=hist_file)
+ assert len(app.history) == 4
+ assert app.history.get(1).statement.raw == 'help'
+ assert app.history.get(2).statement.raw == 'shortcuts'
+ assert app.history.get(3).statement.raw == 'shortcuts'
+ assert app.history.get(4).statement.raw == 'alias'
+
+ # readline only adds a single entry for multiple sequential identical commands
+ # so we check to make sure that cmd2 populated the readline history
+ # using the same rules
+ from cmd2.rl_utils import readline
+ assert readline.get_current_history_length() == 3
+ assert readline.get_history_item(1) == 'help'
+ assert readline.get_history_item(2) == 'shortcuts'
+ assert readline.get_history_item(3) == 'alias'
+#
+# test cmd2's ability to write out history on exit
+# we are testing the _persist_history_on_exit() method, and
+# we assume that the atexit module will call this method
+# properly
+#
+def test_persist_history_ensure_no_error_if_no_histfile(base_app, capsys):
+ # make sure if there is no persistent history file and someone
+ # calls the private method call that we don't get an error
+ base_app._persist_history()
+ out, err = capsys.readouterr()
+ assert not out
+ assert not err
+
+def test_persist_history_permission_error(hist_file, mocker, capsys):
+ app = cmd2.Cmd(persistent_history_file=hist_file)
+ run_cmd(app, 'help')
+ mock_open = mocker.patch('builtins.open')
+ mock_open.side_effect = PermissionError
+ app._persist_history()
+ out, err = capsys.readouterr()
+ assert not out
+ assert 'can not write' in err