diff options
-rw-r--r-- | .appveyor.yml | 2 | ||||
-rw-r--r-- | CHANGELOG.md | 7 | ||||
-rw-r--r-- | cmd2/cmd2.py | 105 | ||||
-rw-r--r-- | cmd2/utils.py | 20 | ||||
-rw-r--r-- | tests/conftest.py | 7 | ||||
-rw-r--r-- | tests/test_cmd2.py | 114 | ||||
-rw-r--r-- | tox.ini | 12 |
7 files changed, 188 insertions, 79 deletions
diff --git a/.appveyor.yml b/.appveyor.yml index ad884315..91f45824 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -4,4 +4,4 @@ install: build: off test_script: - - python -m tox -e py35-win,py36-win + - python -m tox -e py35-win,py36-win,py37-win diff --git a/CHANGELOG.md b/CHANGELOG.md index 764d0061..8fe148ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,12 @@ -## 0.9.3 (July TBD, 2018) +## 0.9.3 (July 12, 2018) * Bug Fixes * Fixed bug when StatementParser ``__init__()`` was called with ``terminators`` equal to ``None`` * Fixed bug when ``Cmd.onecmd()`` was called with a raw ``str`` +* Enhancements + * Added ``--clear`` flag to ``history`` command that clears both the command and readline history. +* Deletions + * The ``CmdResult`` helper class which was *deprecated* in the previous release has now been deleted + * It has been replaced by the improved ``CommandResult`` class ## 0.9.2 (June 28, 2018) * Bug Fixes diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 2cebe3f7..51f2f924 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -371,19 +371,7 @@ class Cmd(cmd.Cmd): except AttributeError: pass - # If persistent readline history is enabled, then read history from file and register to write to file at exit - if persistent_history_file and rl_type != RlType.NONE: - persistent_history_file = os.path.expanduser(persistent_history_file) - try: - readline.read_history_file(persistent_history_file) - # default history len is -1 (infinite), which may grow unruly - readline.set_history_length(persistent_history_length) - except FileNotFoundError: - pass - import atexit - atexit.register(readline.write_history_file, persistent_history_file) - - # initialize plugin system + # initialize plugin system # needs to be done before we call __init__(0) self._initialize_plugin_system() @@ -454,6 +442,37 @@ 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 + 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) @@ -617,7 +636,7 @@ class Cmd(cmd.Cmd): try: self.pipe_proc.stdin.write(msg_str.encode('utf-8', 'replace')) self.pipe_proc.stdin.close() - except (IOError, KeyboardInterrupt): + except (OSError, KeyboardInterrupt): pass # Less doesn't respect ^C, but catches it for its own UI purposes (aborting search etc. inside less) @@ -2634,8 +2653,9 @@ Usage: Usage: unalias [-a] name [name ...] try: with open(filename) as f: interp.runcode(f.read()) - except IOError as e: - self.perror(e) + except OSError as ex: + error_msg = "Error opening script file '{}': {}".format(filename, ex) + self.perror(error_msg, traceback_war=False) bridge = PyscriptBridge(self) self.pystate['run'] = run @@ -2829,6 +2849,7 @@ Paths or arguments that contain spaces must be enclosed in quotes history_parser_group.add_argument('-s', '--script', action='store_true', help='script format; no separation lines') history_parser_group.add_argument('-o', '--output-file', metavar='FILE', help='output commands to a script file') history_parser_group.add_argument('-t', '--transcript', help='output commands and results to a transcript file') + history_parser_group.add_argument('-c', '--clear', action="store_true", help='clears all history') _history_arg_help = """empty all history items a one history item by number a..b, a:b, a:, ..b items by indices (inclusive) @@ -2838,7 +2859,18 @@ a..b, a:b, a:, ..b items by indices (inclusive) @with_argparser(history_parser) def do_history(self, args: argparse.Namespace) -> None: - """View, run, edit, and save previously entered commands.""" + """View, run, edit, save, or clear previously entered commands.""" + + if args.clear: + # Clear command and readline history + self.history.clear() + + 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 cowardly_refuse_to_run = False if args.arg: @@ -3044,25 +3076,30 @@ Script should contain one command per line, just like command would be typed in """ # If arg is None or arg is an empty string this is an error if not arglist: - self.perror('load command requires a file path:', traceback_war=False) + self.perror('load command requires a file path', traceback_war=False) return file_path = arglist[0].strip() expanded_path = os.path.abspath(os.path.expanduser(file_path)) + # Make sure the path exists and we can access it + if not os.path.exists(expanded_path): + self.perror("'{}' does not exist or cannot be accessed".format(expanded_path), traceback_war=False) + return + # Make sure expanded_path points to a file if not os.path.isfile(expanded_path): - self.perror('{} does not exist or is not a file'.format(expanded_path), traceback_war=False) + self.perror("'{}' is not a file".format(expanded_path), traceback_war=False) return # Make sure the file is not empty if os.path.getsize(expanded_path) == 0: - self.perror('{} is empty'.format(expanded_path), traceback_war=False) + self.perror("'{}' is empty".format(expanded_path), traceback_war=False) return # Make sure the file is ASCII or UTF-8 encoded text if not utils.is_text_file(expanded_path): - self.perror('{} is not an ASCII or UTF-8 encoded text file'.format(expanded_path), traceback_war=False) + self.perror("'{}' is not an ASCII or UTF-8 encoded text file".format(expanded_path), traceback_war=False) return try: @@ -3071,8 +3108,8 @@ Script should contain one command per line, just like command would be typed in # self._script_dir list when done. with open(expanded_path, encoding='utf-8') as target: self.cmdqueue = target.read().splitlines() + ['eos'] + self.cmdqueue - except IOError as e: # pragma: no cover - self.perror('Problem accessing script from {}:\n{}'.format(expanded_path, e)) + except OSError as ex: # pragma: no cover + self.perror("Problem accessing script from '{}': {}".format(expanded_path, ex)) return self._script_dir.append(os.path.dirname(expanded_path)) @@ -3408,25 +3445,3 @@ class Statekeeper(object): if self.obj: for attrib in self.attribs: setattr(self.obj, attrib, getattr(self, attrib)) - - -class CmdResult(utils.namedtuple_with_two_defaults('CmdResult', ['out', 'err', 'war'])): - """DEPRECATED: Derive a class to store results from a named tuple so we can tweak dunder methods for convenience. - - This is provided as a convenience and an example for one possible way for end users to store results in - the self._last_result attribute of cmd2.Cmd class instances. See the "python_scripting.py" example for how it can - be used to enable conditional control flow. - - Named tuple attributes - ---------------------- - out - this is intended to store normal output data from the command and can be of any type that makes sense - err: str - (optional) this is intended to store an error message and it being non-empty indicates there was an error - Defaults to an empty string - war: str - (optional) this is intended to store a warning message which isn't quite an error, but of note - Defaults to an empty string. - - NOTE: Named tuples are immutable. So the contents are there for access, not for modification. - """ - def __bool__(self) -> bool: - """If err is an empty string, treat the result as a success; otherwise treat it as a failure.""" - return not self.err diff --git a/cmd2/utils.py b/cmd2/utils.py index 11d48b78..d03e7f6f 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -61,22 +61,6 @@ def namedtuple_with_defaults(typename: str, field_names: Union[str, List[str]], return T -def namedtuple_with_two_defaults(typename: str, field_names: Union[str, List[str]], - default_values: collections.Iterable=('', '')): - """Wrapper around namedtuple which lets you treat the last value as optional. - - :param typename: str - type name for the Named tuple - :param field_names: List[str] or space-separated string of field names - :param default_values: (optional) 2-element tuple containing the default values for last 2 parameters in named tuple - Defaults to an empty string for both of them - :return: namedtuple type - """ - T = collections.namedtuple(typename, field_names) - # noinspection PyUnresolvedReferences - T.__new__.__defaults__ = default_values - return T - - def cast(current: Any, new: str) -> Any: """Tries to force a new value into the same type as the current when trying to set the value for a parameter. @@ -143,7 +127,7 @@ def is_text_file(file_path: str) -> bool: # noinspection PyUnusedLocal if sum(1 for line in f) > 0: valid_text_file = True - except IOError: # pragma: no cover + except OSError: # pragma: no cover pass except UnicodeDecodeError: # The file is not ASCII. Check if it is UTF-8. @@ -153,7 +137,7 @@ def is_text_file(file_path: str) -> bool: # noinspection PyUnusedLocal if sum(1 for line in f) > 0: valid_text_file = True - except IOError: # pragma: no cover + except OSError: # pragma: no cover pass except UnicodeDecodeError: # Not UTF-8 diff --git a/tests/conftest.py b/tests/conftest.py index 90d45bd9..3f3b862e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -37,7 +37,7 @@ Documented commands (type help <topic>): alias Define or display aliases edit Edit a file in a text editor. help List available commands with "help" or detailed help with "help cmd". -history View, run, edit, and save previously entered commands. +history View, run, edit, save, or clear previously entered commands. load Runs commands in script file that is encoded as either ASCII or UTF-8 text. py Invoke python command, shell, or script pyscript Runs a python script file inside the console @@ -49,9 +49,9 @@ unalias Unsets aliases """ # Help text for the history command -HELP_HISTORY = """usage: history [-h] [-r | -e | -s | -o FILE | -t TRANSCRIPT] [arg] +HELP_HISTORY = """usage: history [-h] [-r | -e | -s | -o FILE | -t TRANSCRIPT | -c] [arg] -View, run, edit, and save previously entered commands. +View, run, edit, save, or clear previously entered commands. positional arguments: arg empty all history items @@ -69,6 +69,7 @@ optional arguments: output commands to a script file -t TRANSCRIPT, --transcript TRANSCRIPT output commands and results to a transcript file + -c, --clear clears all history """ # Output from the shortcuts command with default built-in shortcuts diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index b973fdf5..25d1db3f 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -231,7 +231,7 @@ def test_pyscript_with_nonexist_file(base_app, capsys): python_script = 'does_not_exist.py' run_cmd(base_app, "pyscript {}".format(python_script)) out, err = capsys.readouterr() - assert err.startswith("EXCEPTION of type 'FileNotFoundError' occurred with message:") + assert "Error opening script file" in err def test_pyscript_with_exception(base_app, capsys, request): test_dir = os.path.dirname(request.module.__file__) @@ -426,7 +426,7 @@ def test_history_run_all_commands(base_app): out = run_cmd(base_app, 'history -r') # this should generate an error, but we don't currently have a way to # capture stderr in these tests. So we assume that if we got nothing on - # standard out, that the error occured because if the commaned executed + # standard out, that the error occurred because if the command executed # then we should have a list of shortcuts in our output assert out == [] @@ -435,6 +435,23 @@ def test_history_run_one_command(base_app): output = run_cmd(base_app, 'history -r 1') assert output == expected +def test_history_clear(base_app): + # Add commands to history + run_cmd(base_app, 'help') + run_cmd(base_app, 'alias') + + # Make sure history has items + out = run_cmd(base_app, 'history') + assert out + + # Clear the history + run_cmd(base_app, 'history --clear') + + # Make sure history is empty + out = run_cmd(base_app, 'history') + assert out == [] + + def test_base_load(base_app, request): test_dir = os.path.dirname(request.module.__file__) filename = os.path.join(test_dir, 'script.txt') @@ -457,8 +474,7 @@ def test_load_with_empty_args(base_app, capsys): out, err = capsys.readouterr() # The load command requires a file path argument, so we should get an error message - expected = normalize("""ERROR: load command requires a file path:\n""") - assert normalize(str(err)) == expected + assert "load command requires a file path" in str(err) assert base_app.cmdqueue == [] @@ -468,10 +484,18 @@ def test_load_with_nonexistent_file(base_app, capsys): out, err = capsys.readouterr() # The load command requires a path to an existing file - assert str(err).startswith("ERROR") - assert "does not exist or is not a file" in str(err) + assert "does not exist" in str(err) assert base_app.cmdqueue == [] +def test_load_with_directory(base_app, capsys, request): + test_dir = os.path.dirname(request.module.__file__) + + # The way the load command works, we can't directly capture its stdout or stderr + run_cmd(base_app, 'load {}'.format(test_dir)) + out, err = capsys.readouterr() + + assert "is not a file" in str(err) + assert base_app.cmdqueue == [] def test_load_with_empty_file(base_app, capsys, request): test_dir = os.path.dirname(request.module.__file__) @@ -481,8 +505,7 @@ def test_load_with_empty_file(base_app, capsys, request): run_cmd(base_app, 'load {}'.format(filename)) out, err = capsys.readouterr() - # The load command requires non-empty scripts files - assert str(err).startswith("ERROR") + # The load command requires non-empty script files assert "is empty" in str(err) assert base_app.cmdqueue == [] @@ -724,7 +747,7 @@ def test_pipe_to_shell(base_app, capsys): out, err = capsys.readouterr() # Unfortunately with the improved way of piping output to a subprocess, there isn't any good way of getting - # access to the output produced by that subprocess within a unit test, but we can verify that no error occured + # access to the output produced by that subprocess within a unit test, but we can verify that no error occurred assert not err def test_pipe_to_shell_error(base_app, capsys): @@ -1225,7 +1248,7 @@ Other ================================================================================ alias Define or display aliases help List available commands with "help" or detailed help with "help cmd". -history View, run, edit, and save previously entered commands. +history View, run, edit, save, or clear previously entered commands. load Runs commands in script file that is encoded as either ASCII or UTF-8 text. py Invoke python command, shell, or script pyscript Runs a python script file inside the console @@ -1802,3 +1825,74 @@ def test_onecmd_raw_str_quit(base_app): assert stop assert out == '' + +@pytest.fixture(scope="session") +def hist_file(): + fd, filename = tempfile.mkstemp(prefix='hist_file', suffix='.txt') + os.close(fd) + yield filename + # teardown code + try: + os.remove(filename) + 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 + app = cmd2.Cmd(persistent_history_file=hist_file) + out, 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_new_history_file(hist_file, capsys): + import atexit + import readline + + # Remove any existing history file + try: + os.remove(hist_file) + except OSError: + pass + + # Create a new cmd2 app + app = cmd2.Cmd(persistent_history_file=hist_file) + out, 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 + app = cmd2.Cmd(persistent_history_file=test_dir) + out, 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 @@ -1,5 +1,5 @@ [tox] -envlist = py34,py35,py36,py37,py35-win,py36-win +envlist = py34,py35,py36,py37,py35-win,py36-win,py37-win [pytest] testpaths = tests @@ -73,3 +73,13 @@ deps = wcwidth commands = py.test -v +[testenv:py37-win] +deps = + codecov + pyperclip + pyreadline + pytest + pytest-cov +commands = + py.test {posargs} --cov + codecov |