diff options
author | Kevin Van Brunt <kmvanbrunt@gmail.com> | 2019-06-06 16:00:10 -0400 |
---|---|---|
committer | Kevin Van Brunt <kmvanbrunt@gmail.com> | 2019-06-06 16:00:10 -0400 |
commit | a490975a030cf7a366e6436b141a7c3ff3b9b018 (patch) | |
tree | 522ba401fbec1960181222a5ec3a9aa503be3ba2 | |
parent | c4fd5b6403651ccc89976edd4e04549471b4a23b (diff) | |
parent | 432427b46428a2b2234eab7b33bb89597f2f7957 (diff) | |
download | cmd2-git-a490975a030cf7a366e6436b141a7c3ff3b9b018.tar.gz |
Merge branch 'master' into script_refactor
-rw-r--r-- | .gitignore | 2 | ||||
-rw-r--r-- | CHANGELOG.md | 43 | ||||
-rw-r--r-- | cmd2/cmd2.py | 166 | ||||
-rw-r--r-- | cmd2/history.py | 109 | ||||
-rw-r--r-- | docs/freefeatures.rst | 2 | ||||
-rwxr-xr-x | examples/hello_cmd2.py | 2 | ||||
-rw-r--r-- | tests/conftest.py | 4 | ||||
-rw-r--r-- | tests/test_history.py | 468 |
8 files changed, 560 insertions, 236 deletions
@@ -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 |