Repository: python-cmd2/cmd2

Issue:
Consider loading persistent_history_file into cmd2 history in addition to readline history
@tleonhardt previously mentioned this following in https://github.com/python-cmd2/cmd2/issues/265 (when `readline` history was introduced)

> We would need to decide if this is done for `readline` history, `cmd2` history command history, or both. Perhaps there would be two separate options for making each of these persistent?

In the implementation of this feature (https://github.com/python-cmd2/cmd2/pull/270) only `readline` history support was implemented.

I think it would in fact make sense for this file to also affect the `cmd2` history command, as this is the behavior that other shells exhibit with their history files. It seems more consistent and expected to have both histories linked up.

Discussion:

Patch:
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 2a43e961..e372f438 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,13 +23,25 @@
     * 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
-* Potentially breaking changes
+    * 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.
+
+* 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.
 * **Python 3.4 EOL notice**
     * Python 3.4 reached its [end of life](https://www.python.org/dev/peps/pep-0429/) on March 18, 2019
     * This is the last release of `cmd2` which will support Python 3.4
@@ -38,7 +50,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.
@@ -60,7 +72,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.
@@ -98,7 +110,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.
@@ -118,7 +130,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
@@ -139,9 +151,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`.
@@ -167,8 +179,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``
@@ -180,10 +192,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
@@ -193,18 +205,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 9d36e1b4..387fe6d9 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', 'eos', '_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 eos'''.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'
@@ -463,41 +466,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))
 
@@ -2418,8 +2389,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"
@@ -2449,8 +2420,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.")
 
@@ -2601,17 +2572,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"
@@ -3097,8 +3068,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='?')
@@ -3366,6 +3337,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"
@@ -3397,10 +3371,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
@@ -3418,18 +3393,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:
@@ -3438,9 +3413,9 @@ class Cmd(cmd.Cmd):
                             traceback_war=False)
             else:
                 for runme in history:
-                    self.pfeedback(runme)
+                    self.pfeedback(runme.raw)
                     if runme:
-                        self.onecmd_plus_hooks(runme)
+                        self.onecmd_plus_hooks(runme.raw)
         elif args.edit:
             import tempfile
             fd, fname = tempfile.mkstemp(suffix='.txt', text=True)
@@ -3449,7 +3424,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)
                 self.do_load(fname)
@@ -3460,11 +3435,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:
@@ -3476,6 +3451,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) -> None:
         """Generate a transcript file from a given history of commands."""
         import io
@@ -3509,6 +3557,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)
@@ -3641,7 +3691,7 @@ class Cmd(cmd.Cmd):
             return
 
         if args.transcript:
-            self._generate_transcript(script_commands, os.path.expanduser(args.transcript))
+            self._generate_transcript(script_commands, args.transcript)
             return
 
         self.cmdqueue = script_commands + ['eos'] + self.cmdqueue
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())


Test patch:
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