diff options
31 files changed, 111 insertions, 51 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fe4e734..2a43e961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ * Fixed parsing issue in case where output redirection appears before a pipe. In that case, the pipe was given precedence even though it appeared later in the command. * Fixed issue where quotes around redirection file paths were being lost in `Statement.expanded_command_line()` + * Fixed a bug in how line numbers were calculated for transcript testing * Enhancements * Added capability to chain pipe commands and redirect their output (e.g. !ls -l | grep user | wc -l > out.txt) * `pyscript` limits a command's stdout capture to the same period that redirection does. @@ -21,11 +22,14 @@ readline. This makes debugging a lot easier since readline suppresses these exceptions. * 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 * 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 * **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 @@ -227,6 +227,7 @@ A sample application for cmd2. """ import argparse import random +import sys import cmd2 class CmdLineApp(cmd2.Cmd): @@ -294,8 +295,8 @@ class CmdLineApp(cmd2.Cmd): self.poutput(' '.join(output)) if __name__ == '__main__': - c = CmdLineApp() - c.cmdloop() + app = CmdLineApp() + sys.exit(app.cmdloop()) ``` The following is a sample session running example.py. diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index f5a2a844..f54a0652 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -553,8 +553,8 @@ class Cmd(cmd.Cmd): # This boolean flag determines whether or not the cmd2 application can interact with the clipboard self.can_clip = can_clip - # This determines if a non-zero exit code should be used when exiting the application - self.exit_code = None + # This determines the value returned by cmdloop() when exiting the application + self.exit_code = 0 # This lock should be acquired before doing any asynchronous changes to the terminal to # ensure the updates to the terminal don't interfere with the input being typed or output @@ -3684,7 +3684,12 @@ class Cmd(cmd.Cmd): sys.argv = [sys.argv[0]] # the --test argument upsets unittest.main() testcase = TestMyAppCase() runner = unittest.TextTestRunner() - runner.run(testcase) + test_results = runner.run(testcase) + if test_results.wasSuccessful(): + self.poutput('Tests passed', color=Fore.LIGHTGREEN_EX) + else: + self.perror('Tests failed', traceback_war=False) + self.exit_code = -1 def async_alert(self, alert_msg: str, new_prompt: Optional[str] = None) -> None: # pragma: no cover """ @@ -3932,7 +3937,7 @@ class Cmd(cmd.Cmd): """ self.decolorized_write(sys.stderr, "{}\n".format(message_to_print)) - def cmdloop(self, intro: Optional[str] = None) -> None: + def cmdloop(self, intro: Optional[str] = None) -> int: """This is an outer wrapper around _cmdloop() which deals with extra features provided by cmd2. _cmdloop() provides the main loop equivalent to cmd.cmdloop(). This is a wrapper around that which deals with @@ -3940,6 +3945,7 @@ class Cmd(cmd.Cmd): - commands at invocation - transcript testing - intro banner + - exit code :param intro: if provided this overrides self.intro and serves as the intro banner printed once at start """ @@ -4002,8 +4008,7 @@ class Cmd(cmd.Cmd): # Restore the original signal handler signal.signal(signal.SIGINT, original_sigint_handler) - if self.exit_code is not None: - sys.exit(self.exit_code) + return self.exit_code ### # diff --git a/cmd2/transcript.py b/cmd2/transcript.py index 6a954bce..a635c1d3 100644 --- a/cmd2/transcript.py +++ b/cmd2/transcript.py @@ -71,6 +71,7 @@ class Cmd2TestCase(unittest.TestCase): line = next(transcript) except StopIteration: line = '' + line_num += 1 # Read the entirety of a multi-line command while line.startswith(self.cmdapp.continuation_prompt): command.append(line[len(self.cmdapp.continuation_prompt):]) diff --git a/docs/unfreefeatures.rst b/docs/unfreefeatures.rst index 071a15b2..81be76d8 100644 --- a/docs/unfreefeatures.rst +++ b/docs/unfreefeatures.rst @@ -204,8 +204,8 @@ Presents numbered options to user, as bash ``select``. Exit code to shell ================== The ``self.exit_code`` attribute of your ``cmd2`` application controls -what exit code is sent to the shell when your application exits from -``cmdloop()``. +what exit code is returned from ``cmdloop()`` when it completes. It is your job to make sure that +this exit code gets sent to the shell when your application exits by calling ``sys.exit(app.cmdloop())``. Asynchronous Feedback diff --git a/examples/alias_startup.py b/examples/alias_startup.py index 7c70bcd9..b765e34c 100755 --- a/examples/alias_startup.py +++ b/examples/alias_startup.py @@ -21,5 +21,6 @@ class AliasAndStartup(cmd2.Cmd): if __name__ == '__main__': + import sys app = AliasAndStartup() - app.cmdloop() + sys.exit(app.cmdloop()) diff --git a/examples/arg_print.py b/examples/arg_print.py index edcc8444..48bcbd13 100755 --- a/examples/arg_print.py +++ b/examples/arg_print.py @@ -63,5 +63,6 @@ class ArgumentAndOptionPrinter(cmd2.Cmd): if __name__ == '__main__': + import sys app = ArgumentAndOptionPrinter() - app.cmdloop() + sys.exit(app.cmdloop()) diff --git a/examples/async_printing.py b/examples/async_printing.py index d0716bbb..3089070f 100755 --- a/examples/async_printing.py +++ b/examples/async_printing.py @@ -197,6 +197,7 @@ class AlerterApp(cmd2.Cmd): if __name__ == '__main__': + import sys app = AlerterApp() app.set_window_title("Asynchronous Printer Test") - app.cmdloop() + sys.exit(app.cmdloop()) diff --git a/examples/cmd_as_argument.py b/examples/cmd_as_argument.py index df7e1d76..9eb0befb 100755 --- a/examples/cmd_as_argument.py +++ b/examples/cmd_as_argument.py @@ -14,7 +14,6 @@ $ python cmd_as_argument.py speak -p hello there import argparse import random -import sys import cmd2 @@ -101,13 +100,17 @@ def main(argv=None): c = CmdLineApp() + sys_exit_code = 0 if args.command: # we have a command, run it and then exit c.onecmd_plus_hooks('{} {}'.format(args.command, ' '.join(args.command_args))) else: # we have no command, drop into interactive mode - c.cmdloop() + sys_exit_code = c.cmdloop() + + return sys_exit_code if __name__ == '__main__': + import sys sys.exit(main()) diff --git a/examples/colors.py b/examples/colors.py index ea0bca39..fdc0e0bd 100755 --- a/examples/colors.py +++ b/examples/colors.py @@ -138,5 +138,6 @@ class CmdLineApp(cmd2.Cmd): if __name__ == '__main__': + import sys c = CmdLineApp() - c.cmdloop() + sys.exit(c.cmdloop()) diff --git a/examples/decorator_example.py b/examples/decorator_example.py index d8088c0a..e268c615 100755 --- a/examples/decorator_example.py +++ b/examples/decorator_example.py @@ -11,7 +11,6 @@ all the commands in the transcript against decorator_example.py, verifying that the output produced matches the transcript. """ import argparse -import sys from typing import List import cmd2 @@ -89,6 +88,8 @@ class CmdLineApp(cmd2.Cmd): if __name__ == '__main__': + import sys + # You can do your custom Argparse parsing here to meet your application's needs parser = argparse.ArgumentParser(description='Process the arguments however you like.') @@ -114,4 +115,4 @@ if __name__ == '__main__': c = CmdLineApp() # And run your cmd2 application - c.cmdloop() + sys.exit(c.cmdloop()) diff --git a/examples/environment.py b/examples/environment.py index e899cce8..9e611f08 100755 --- a/examples/environment.py +++ b/examples/environment.py @@ -3,7 +3,6 @@ """ A sample application for cmd2 demonstrating customized environment parameters """ - import cmd2 @@ -34,5 +33,6 @@ class EnvironmentApp(cmd2.Cmd): if __name__ == '__main__': + import sys c = EnvironmentApp() - c.cmdloop() + sys.exit(c.cmdloop()) diff --git a/examples/example.py b/examples/example.py index 9f9c0304..a1ec893c 100755 --- a/examples/example.py +++ b/examples/example.py @@ -10,9 +10,8 @@ Running `python example.py -t transcript_regex.txt` will run all the commands in the transcript against example.py, verifying that the output produced matches the transcript. """ - -import random import argparse +import random import cmd2 @@ -82,5 +81,6 @@ class CmdLineApp(cmd2.Cmd): if __name__ == '__main__': + import sys c = CmdLineApp() - c.cmdloop() + sys.exit(c.cmdloop()) diff --git a/examples/exit_code.py b/examples/exit_code.py index 8ae2d310..f4b19091 100755 --- a/examples/exit_code.py +++ b/examples/exit_code.py @@ -3,7 +3,6 @@ """A simple example demonstrating the following how to emit a non-zero exit code in your cmd2 application. """ import cmd2 -import sys from typing import List @@ -29,15 +28,12 @@ Usage: exit [exit_code] self.perror("{} isn't a valid integer exit code".format(arg_list[0])) self.exit_code = -1 - self._should_quit = True - return self._STOP_AND_EXIT - - def postloop(self) -> None: - """Hook method executed once when the cmdloop() method is about to return.""" - code = self.exit_code if self.exit_code is not None else 0 - self.poutput('{!r} exiting with code: {}'.format(sys.argv[0], code)) + return True if __name__ == '__main__': + import sys app = ReplWithExitCode() - app.cmdloop() + sys_exit_code = app.cmdloop() + app.poutput('{!r} exiting with code: {}'.format(sys.argv[0], sys_exit_code)) + sys.exit(sys_exit_code) diff --git a/examples/hello_cmd2.py b/examples/hello_cmd2.py index 397856a6..395663f2 100755 --- a/examples/hello_cmd2.py +++ b/examples/hello_cmd2.py @@ -6,6 +6,7 @@ This is intended to be a completely bare-bones cmd2 application suitable for rap from cmd2 import cmd2 if __name__ == '__main__': + import sys # If run as the main application, simply start a bare-bones cmd2 application with only built-in functionality. # Set "use_ipython" to True to include the ipy command if IPython is installed, which supports advanced interactive @@ -13,4 +14,4 @@ if __name__ == '__main__': app = cmd2.Cmd(use_ipython=True, persistent_history_file='cmd2_history.txt') app.locals_in_py = True # Enable access to "self" within the py command app.debug = True # Show traceback if/when an exception occurs - app.cmdloop() + sys.exit(app.cmdloop()) diff --git a/examples/help_categories.py b/examples/help_categories.py index 62351e81..80f367fa 100755 --- a/examples/help_categories.py +++ b/examples/help_categories.py @@ -157,5 +157,6 @@ class HelpCategories(cmd2.Cmd): if __name__ == '__main__': + import sys c = HelpCategories() - c.cmdloop() + sys.exit(c.cmdloop()) diff --git a/examples/hooks.py b/examples/hooks.py index dd21e58a..c533c696 100755 --- a/examples/hooks.py +++ b/examples/hooks.py @@ -111,5 +111,6 @@ class CmdLineApp(cmd2.Cmd): if __name__ == '__main__': + import sys c = CmdLineApp() - c.cmdloop() + sys.exit(c.cmdloop()) diff --git a/examples/paged_output.py b/examples/paged_output.py index a0674a62..b3824012 100755 --- a/examples/paged_output.py +++ b/examples/paged_output.py @@ -54,5 +54,6 @@ class PagedOutput(cmd2.Cmd): if __name__ == '__main__': + import sys app = PagedOutput() - app.cmdloop() + sys.exit(app.cmdloop()) diff --git a/examples/persistent_history.py b/examples/persistent_history.py index 61e26b9c..12d8b813 100755 --- a/examples/persistent_history.py +++ b/examples/persistent_history.py @@ -30,4 +30,4 @@ if __name__ == '__main__': history_file = sys.argv[1] app = Cmd2PersistentHistory(hist_file=history_file) - app.cmdloop() + sys.exit(app.cmdloop()) diff --git a/examples/pirate.py b/examples/pirate.py index 994ca245..9abbe4e6 100755 --- a/examples/pirate.py +++ b/examples/pirate.py @@ -55,6 +55,7 @@ class Pirate(cmd2.Cmd): self.poutput('Now we gots {0} doubloons'.format(self.gold)) if self.gold < 0: self.poutput("Off to debtorrr's prison.") + self.exit_code = -1 stop = True return stop @@ -99,6 +100,9 @@ class Pirate(cmd2.Cmd): if __name__ == '__main__': + import sys # Create an instance of the Pirate derived class and enter the REPL with cmdlooop(). pirate = Pirate() - pirate.cmdloop() + sys_exit_code = pirate.cmdloop() + print('Exiting with code: {!r}'.format(sys_exit_code)) + sys.exit(sys_exit_code) diff --git a/examples/plumbum_colors.py b/examples/plumbum_colors.py index 6daa5312..774dc7e4 100755 --- a/examples/plumbum_colors.py +++ b/examples/plumbum_colors.py @@ -141,5 +141,6 @@ class CmdLineApp(cmd2.Cmd): if __name__ == '__main__': + import sys c = CmdLineApp() - c.cmdloop() + sys.exit(c.cmdloop()) diff --git a/examples/python_scripting.py b/examples/python_scripting.py index 7847b8b6..da7d0f6a 100755 --- a/examples/python_scripting.py +++ b/examples/python_scripting.py @@ -117,5 +117,6 @@ class CmdLineApp(cmd2.Cmd): if __name__ == '__main__': + import sys c = CmdLineApp() - c.cmdloop() + sys.exit(c.cmdloop()) diff --git a/examples/remove_unused.py b/examples/remove_unused.py index 8a567123..62103022 100755 --- a/examples/remove_unused.py +++ b/examples/remove_unused.py @@ -26,5 +26,6 @@ class RemoveUnusedBuiltinCommands(cmd2.Cmd): if __name__ == '__main__': + import sys app = RemoveUnusedBuiltinCommands() - app.cmdloop() + sys.exit(app.cmdloop()) diff --git a/examples/subcommands.py b/examples/subcommands.py index 83c29393..d1b7c9db 100755 --- a/examples/subcommands.py +++ b/examples/subcommands.py @@ -114,5 +114,6 @@ class SubcommandsExample(cmd2.Cmd): if __name__ == '__main__': + import sys app = SubcommandsExample() - app.cmdloop() + sys.exit(app.cmdloop()) diff --git a/examples/tab_autocomp_dynamic.py b/examples/tab_autocomp_dynamic.py index 93b72442..03e46f8a 100755 --- a/examples/tab_autocomp_dynamic.py +++ b/examples/tab_autocomp_dynamic.py @@ -232,5 +232,6 @@ class TabCompleteExample(cmd2.Cmd): if __name__ == '__main__': + import sys app = TabCompleteExample() - app.cmdloop() + sys.exit(app.cmdloop()) diff --git a/examples/tab_autocompletion.py b/examples/tab_autocompletion.py index 3f06a274..6883c423 100755 --- a/examples/tab_autocompletion.py +++ b/examples/tab_autocompletion.py @@ -540,5 +540,6 @@ class TabCompleteExample(cmd2.Cmd): if __name__ == '__main__': + import sys app = TabCompleteExample() - app.cmdloop() + sys.exit(app.cmdloop()) diff --git a/examples/tab_completion.py b/examples/tab_completion.py index 77d62988..48d7cb05 100755 --- a/examples/tab_completion.py +++ b/examples/tab_completion.py @@ -74,5 +74,6 @@ class TabCompleteExample(cmd2.Cmd): if __name__ == '__main__': + import sys app = TabCompleteExample() - app.cmdloop() + sys.exit(app.cmdloop()) diff --git a/examples/table_display.py b/examples/table_display.py index 04415afd..dcde7a81 100755 --- a/examples/table_display.py +++ b/examples/table_display.py @@ -195,6 +195,7 @@ class TableDisplay(cmd2.Cmd): if __name__ == '__main__': + import sys app = TableDisplay() app.debug = True - app.cmdloop() + sys.exit(app.cmdloop()) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 7a17cfac..1aafefc2 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1950,8 +1950,7 @@ Usage: exit [exit_code] def postloop(self) -> None: """Hook method executed once when the cmdloop() method is about to return.""" - code = self.exit_code if self.exit_code is not None else 0 - self.poutput('exiting with code: {}'.format(code)) + self.poutput('exiting with code: {}'.format(self.exit_code)) @pytest.fixture def exit_code_repl(): @@ -1991,8 +1990,7 @@ def test_exit_code_nonzero(exit_code_repl): expected = 'exiting with code: 23\n' with mock.patch.object(sys, 'argv', testargs): # Run the command loop - with pytest.raises(SystemExit): - app.cmdloop() + app.cmdloop() out = app.stdout.getvalue() assert out == expected diff --git a/tests/test_transcript.py b/tests/test_transcript.py index acdbe703..7a2bc38a 100644 --- a/tests/test_transcript.py +++ b/tests/test_transcript.py @@ -127,7 +127,8 @@ def test_transcript(request, capsys, filename, feedback_to_output): testargs = ['prog', '-t', transcript_file] with mock.patch.object(sys, 'argv', testargs): # Run the command loop - app.cmdloop() + sys_exit_code = app.cmdloop() + assert sys_exit_code == 0 # Check for the unittest "OK" condition for the 1 test which ran expected_start = ".\n----------------------------------------------------------------------\nRan 1 test in" @@ -247,3 +248,29 @@ def test_parse_transcript_expected(expected, transformed): testcase = TestMyAppCase() assert testcase._transform_transcript_expected(expected) == transformed + + +def test_transcript_failure(request, capsys): + # Create a cmd2.Cmd() instance and make sure basic settings are + # like we want for test + app = CmdLineApp() + app.feedback_to_output = False + + # Get location of the transcript + test_dir = os.path.dirname(request.module.__file__) + transcript_file = os.path.join(test_dir, 'transcripts', 'failure.txt') + + # Need to patch sys.argv so cmd2 doesn't think it was called with + # arguments equal to the py.test args + testargs = ['prog', '-t', transcript_file] + with mock.patch.object(sys, 'argv', testargs): + # Run the command loop + sys_exit_code = app.cmdloop() + assert sys_exit_code != 0 + + # Check for the unittest "OK" condition for the 1 test which ran + expected_start = "F\n======================================================================\nFAIL: runTest" + expected_end = "s\n\nFAILED (failures=1)\nTests failed\n" + _, err = capsys.readouterr() + assert err.startswith(expected_start) + assert err.endswith(expected_end) diff --git a/tests/transcripts/failure.txt b/tests/transcripts/failure.txt new file mode 100644 index 00000000..4ef56e72 --- /dev/null +++ b/tests/transcripts/failure.txt @@ -0,0 +1,4 @@ +# This is an example of a transcript test which will fail + +(Cmd) say -r 3 -s yabba dabba do +foo bar baz |