From 11e3eabbff8f80c9c85c04b7e9d6071246856bcf Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 23 Aug 2018 13:12:46 -0400 Subject: Removed Statement.args since it was redundant. Replaced with already parsed list of args with quotes preserved. --- cmd2/cmd2.py | 2 +- cmd2/parsing.py | 37 ++++------- tests/test_parsing.py | 181 +++++++++++++++++++++++--------------------------- 3 files changed, 97 insertions(+), 123 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 83a5a7c8..be672465 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1717,7 +1717,7 @@ class Cmd(cmd.Cmd): :return: tuple containing (command, args, line) """ statement = self.statement_parser.parse_command_only(line) - return statement.command, statement.args, statement.command_and_args + return statement.command, statement, statement.command_and_args def onecmd_plus_hooks(self, line: str) -> bool: """Top-level function called by cmdloop() to handle parsing a line and running the command and all of its hooks. diff --git a/cmd2/parsing.py b/cmd2/parsing.py index 875e54c9..2a4ae56f 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -5,7 +5,7 @@ import os import re import shlex -from typing import List, Tuple, Dict +from typing import List, Tuple, Dict, Union from . import constants from . import utils @@ -33,10 +33,10 @@ class Statement(str): :var multiline_command: if the command is a multiline command, the name of the command, otherwise None :type command: str or None - :var args: the arguments to the command, not including any output + :var arg_list: list of arguments to the command, not including any output redirection or terminators. quoted arguments remain quoted. - :type args: str or None + :type arg_list: list :var: argv: a list of arguments a la sys.argv. Quotes, if any, are removed from the elements of the list, and aliases and shortcuts are expanded @@ -61,7 +61,7 @@ class Statement(str): *, raw: str = None, command: str = None, - args: str = None, + arg_list: List[str] = None, argv: List[str] = None, multiline_command: str = None, terminator: str = None, @@ -78,7 +78,9 @@ class Statement(str): stmt = str.__new__(cls, obj) object.__setattr__(stmt, "raw", raw) object.__setattr__(stmt, "command", command) - object.__setattr__(stmt, "args", args) + if arg_list is None: + arg_list = [] + object.__setattr__(stmt, "arg_list", arg_list) if argv is None: argv = [] object.__setattr__(stmt, "argv", argv) @@ -96,26 +98,15 @@ class Statement(str): Quoted arguments remain quoted. """ - if self.command and self.args: - rtn = '{} {}'.format(self.command, self.args) + if self.command and self: + rtn = '{} {}'.format(self.command, self) elif self.command: - # we are trusting that if we get here that self.args is None + # there were no arguments to the command rtn = self.command else: rtn = None return rtn - @property - def arg_list(self) -> List[str]: - """ - Returns a list of the arguments to the command, not including any output - redirection or terminators. quoted arguments remain quoted. - """ - if self.args is None: - return [] - - return self.args.split() - def __setattr__(self, name, value): """Statement instances should feel immutable; raise ValueError""" raise ValueError @@ -403,7 +394,7 @@ class StatementParser: statement = Statement('' if args is None else args, raw=line, command=command, - args=args, + arg_list=[] if len(argv) <= 1 else argv[1:], argv=list(map(lambda x: utils.strip_quotes(x), argv)), multiline_command=multiline_command, terminator=terminator, @@ -428,10 +419,9 @@ class StatementParser: values in the following attributes: - raw - command - - args Different from parse(), this method does not remove redundant whitespace - within statement.args. It does however, ensure args does not have + within the statement. It does however, ensure statement does not have leading or trailing whitespace. """ # expand shortcuts and aliases @@ -453,7 +443,7 @@ class StatementParser: # no trailing whitespace args = line[match.end(2):].rstrip() # if the command is none that means the input was either empty - # or something wierd like '>'. args should be None if we couldn't + # or something weird like '>'. args should be None if we couldn't # parse a command if not command or not args: args = None @@ -470,7 +460,6 @@ class StatementParser: statement = Statement('' if args is None else args, raw=rawinput, command=command, - args=args, multiline_command=multiline_command, ) return statement diff --git a/tests/test_parsing.py b/tests/test_parsing.py index de4c637e..e1dfa982 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -33,8 +33,7 @@ def default_parser(): def test_parse_empty_string_default(default_parser): statement = default_parser.parse('') - assert not statement.command - assert not statement.args + assert statement.command is None assert statement == '' assert statement.raw == '' @@ -54,8 +53,7 @@ def test_tokenize_default(default_parser, line, tokens): def test_parse_empty_string(parser): statement = parser.parse('') - assert not statement.command - assert not statement.args + assert statement.command is None assert statement == '' assert statement.raw == '' @@ -98,9 +96,9 @@ def test_command_and_args(parser, tokens, command, args): def test_parse_single_word(parser, line): statement = parser.parse(line) assert statement.command == line - assert statement.args is None assert statement == '' assert statement.argv == [utils.strip_quotes(line)] + assert not statement.arg_list @pytest.mark.parametrize('line,terminator', [ ('termbare;', ';'), @@ -111,9 +109,9 @@ def test_parse_single_word(parser, line): def test_parse_word_plus_terminator(parser, line, terminator): statement = parser.parse(line) assert statement.command == 'termbare' - assert statement.args is None assert statement == '' assert statement.argv == ['termbare'] + assert not statement.arg_list assert statement.terminator == terminator @pytest.mark.parametrize('line,terminator', [ @@ -125,9 +123,9 @@ def test_parse_word_plus_terminator(parser, line, terminator): def test_parse_suffix_after_terminator(parser, line, terminator): statement = parser.parse(line) assert statement.command == 'termbare' - assert statement.args is None assert statement == '' assert statement.argv == ['termbare'] + assert not statement.arg_list assert statement.terminator == terminator assert statement.suffix == 'suffx' @@ -135,72 +133,74 @@ def test_parse_command_with_args(parser): line = 'command with args' statement = parser.parse(line) assert statement.command == 'command' - assert statement.args == 'with args' - assert statement == statement.args + assert statement == 'with args' assert statement.argv == ['command', 'with', 'args'] + assert statement.arg_list == statement.argv[1:] def test_parse_command_with_quoted_args(parser): line = 'command with "quoted args" and "some not"' statement = parser.parse(line) assert statement.command == 'command' - assert statement.args == 'with "quoted args" and "some not"' - assert statement == statement.args + assert statement == 'with "quoted args" and "some not"' assert statement.argv == ['command', 'with', 'quoted args', 'and', 'some not'] + assert statement.arg_list == ['with', '"quoted args"', 'and', '"some not"'] def test_parse_command_with_args_terminator_and_suffix(parser): line = 'command with args and terminator; and suffix' statement = parser.parse(line) assert statement.command == 'command' - assert statement.args == "with args and terminator" + assert statement == "with args and terminator" assert statement.argv == ['command', 'with', 'args', 'and', 'terminator'] - assert statement == statement.args + assert statement.arg_list == statement.argv[1:] assert statement.terminator == ';' assert statement.suffix == 'and suffix' def test_parse_hashcomment(parser): statement = parser.parse('hi # this is all a comment') assert statement.command == 'hi' - assert statement.args is None assert statement == '' assert statement.argv == ['hi'] + assert not statement.arg_list def test_parse_c_comment(parser): statement = parser.parse('hi /* this is | all a comment */') assert statement.command == 'hi' - assert statement.args is None assert statement.argv == ['hi'] + assert not statement.arg_list assert statement == '' - assert not statement.pipe_to + assert statement.pipe_to is None def test_parse_c_comment_empty(parser): statement = parser.parse('/* this is | all a comment */') - assert not statement.command - assert not statement.args - assert not statement.pipe_to + assert statement.command is None + assert statement.pipe_to is None assert not statement.argv + assert not statement.arg_list assert statement == '' def test_parse_c_comment_no_closing(parser): statement = parser.parse('cat /tmp/*.txt') assert statement.command == 'cat' - assert statement.args == '/tmp/*.txt' - assert not statement.pipe_to + assert statement == '/tmp/*.txt' + assert statement.pipe_to is None assert statement.argv == ['cat', '/tmp/*.txt'] + assert statement.arg_list == statement.argv[1:] def test_parse_c_comment_multiple_opening(parser): statement = parser.parse('cat /tmp/*.txt /tmp/*.cfg') assert statement.command == 'cat' - assert statement.args == '/tmp/*.txt /tmp/*.cfg' - assert not statement.pipe_to + assert statement == '/tmp/*.txt /tmp/*.cfg' + assert statement.pipe_to is None assert statement.argv == ['cat', '/tmp/*.txt', '/tmp/*.cfg'] + assert statement.arg_list == statement.argv[1:] def test_parse_what_if_quoted_strings_seem_to_start_comments(parser): statement = parser.parse('what if "quoted strings /* seem to " start comments?') assert statement.command == 'what' - assert statement.args == 'if "quoted strings /* seem to " start comments?' + assert statement == 'if "quoted strings /* seem to " start comments?' assert statement.argv == ['what', 'if', 'quoted strings /* seem to ', 'start', 'comments?'] - assert statement == statement.args - assert not statement.pipe_to + assert statement.arg_list == ['if', '"quoted strings /* seem to "', 'start', 'comments?'] + assert statement.pipe_to is None @pytest.mark.parametrize('line',[ 'simple | piped', @@ -209,27 +209,27 @@ def test_parse_what_if_quoted_strings_seem_to_start_comments(parser): def test_parse_simple_pipe(parser, line): statement = parser.parse(line) assert statement.command == 'simple' - assert statement.args is None assert statement == '' assert statement.argv == ['simple'] + assert not statement.arg_list assert statement.pipe_to == ['piped'] def test_parse_double_pipe_is_not_a_pipe(parser): line = 'double-pipe || is not a pipe' statement = parser.parse(line) assert statement.command == 'double-pipe' - assert statement.args == '|| is not a pipe' - assert statement == statement.args + assert statement == '|| is not a pipe' assert statement.argv == ['double-pipe', '||', 'is', 'not', 'a', 'pipe'] - assert not statement.pipe_to + assert statement.arg_list == statement.argv[1:] + assert statement.pipe_to is None def test_parse_complex_pipe(parser): line = 'command with args, terminator&sufx | piped' statement = parser.parse(line) assert statement.command == 'command' - assert statement.args == "with args, terminator" + assert statement == "with args, terminator" assert statement.argv == ['command', 'with', 'args,', 'terminator'] - assert statement == statement.args + assert statement.arg_list == statement.argv[1:] assert statement.terminator == '&' assert statement.suffix == 'sufx' assert statement.pipe_to == ['piped'] @@ -243,7 +243,6 @@ def test_parse_complex_pipe(parser): def test_parse_redirect(parser,line, output): statement = parser.parse(line) assert statement.command == 'help' - assert statement.args is None assert statement == '' assert statement.output == output assert statement.output_to == 'out.txt' @@ -252,9 +251,9 @@ def test_parse_redirect_with_args(parser): line = 'output into > afile.txt' statement = parser.parse(line) assert statement.command == 'output' - assert statement.args == 'into' - assert statement == statement.args + assert statement == 'into' assert statement.argv == ['output', 'into'] + assert statement.arg_list == statement.argv[1:] assert statement.output == '>' assert statement.output_to == 'afile.txt' @@ -262,9 +261,9 @@ def test_parse_redirect_with_dash_in_path(parser): line = 'output into > python-cmd2/afile.txt' statement = parser.parse(line) assert statement.command == 'output' - assert statement.args == 'into' - assert statement == statement.args + assert statement == 'into' assert statement.argv == ['output', 'into'] + assert statement.arg_list == statement.argv[1:] assert statement.output == '>' assert statement.output_to == 'python-cmd2/afile.txt' @@ -272,9 +271,9 @@ def test_parse_redirect_append(parser): line = 'output appended to >> /tmp/afile.txt' statement = parser.parse(line) assert statement.command == 'output' - assert statement.args == 'appended to' - assert statement == statement.args + assert statement == 'appended to' assert statement.argv == ['output', 'appended', 'to'] + assert statement.arg_list == statement.argv[1:] assert statement.output == '>>' assert statement.output_to == '/tmp/afile.txt' @@ -282,22 +281,22 @@ def test_parse_pipe_and_redirect(parser): line = 'output into;sufx | pipethrume plz > afile.txt' statement = parser.parse(line) assert statement.command == 'output' - assert statement.args == 'into' - assert statement == statement.args + assert statement == 'into' assert statement.argv == ['output', 'into'] + assert statement.arg_list == statement.argv[1:] assert statement.terminator == ';' assert statement.suffix == 'sufx' assert statement.pipe_to == ['pipethrume', 'plz', '>', 'afile.txt'] - assert not statement.output - assert not statement.output_to + assert statement.output is None + assert statement.output_to is None def test_parse_output_to_paste_buffer(parser): line = 'output to paste buffer >> ' statement = parser.parse(line) assert statement.command == 'output' - assert statement.args == 'to paste buffer' - assert statement == statement.args + assert statement == 'to paste buffer' assert statement.argv == ['output', 'to', 'paste', 'buffer'] + assert statement.arg_list == statement.argv[1:] assert statement.output == '>>' def test_parse_redirect_inside_terminator(parser): @@ -307,9 +306,9 @@ def test_parse_redirect_inside_terminator(parser): line = 'has > inside;' statement = parser.parse(line) assert statement.command == 'has' - assert statement.args == '> inside' - assert statement == statement.args + assert statement == '> inside' assert statement.argv == ['has', '>', 'inside'] + assert statement.arg_list == statement.argv[1:] assert statement.terminator == ';' @pytest.mark.parametrize('line,terminator',[ @@ -325,9 +324,9 @@ def test_parse_redirect_inside_terminator(parser): def test_parse_multiple_terminators(parser, line, terminator): statement = parser.parse(line) assert statement.multiline_command == 'multiline' - assert statement.args == 'with | inside' - assert statement == statement.args + assert statement == 'with | inside' assert statement.argv == ['multiline', 'with', '|', 'inside'] + assert statement.arg_list == statement.argv[1:] assert statement.terminator == terminator def test_parse_unfinished_multiliine_command(parser): @@ -335,10 +334,10 @@ def test_parse_unfinished_multiliine_command(parser): statement = parser.parse(line) assert statement.multiline_command == 'multiline' assert statement.command == 'multiline' - assert statement.args == 'has > inside an unfinished command' - assert statement == statement.args + assert statement == 'has > inside an unfinished command' assert statement.argv == ['multiline', 'has', '>', 'inside', 'an', 'unfinished', 'command'] - assert not statement.terminator + assert statement.arg_list == statement.argv[1:] + assert statement.terminator is None @pytest.mark.parametrize('line,terminator',[ ('multiline has > inside;', ';'), @@ -350,9 +349,9 @@ def test_parse_unfinished_multiliine_command(parser): def test_parse_multiline_command_ignores_redirectors_within_it(parser, line, terminator): statement = parser.parse(line) assert statement.multiline_command == 'multiline' - assert statement.args == 'has > inside' - assert statement == statement.args + assert statement == 'has > inside' assert statement.argv == ['multiline', 'has', '>', 'inside'] + assert statement.arg_list == statement.argv[1:] assert statement.terminator == terminator def test_parse_multiline_with_incomplete_comment(parser): @@ -362,8 +361,9 @@ def test_parse_multiline_with_incomplete_comment(parser): statement = parser.parse(line) assert statement.multiline_command == 'multiline' assert statement.command == 'multiline' - assert statement.args == 'command /* with unclosed comment' + assert statement == 'command /* with unclosed comment' assert statement.argv == ['multiline', 'command', '/*', 'with', 'unclosed', 'comment'] + assert statement.arg_list == statement.argv[1:] assert statement.terminator == ';' def test_parse_multiline_with_complete_comment(parser): @@ -371,9 +371,9 @@ def test_parse_multiline_with_complete_comment(parser): statement = parser.parse(line) assert statement.multiline_command == 'multiline' assert statement.command == 'multiline' - assert statement.args == 'command is done' - assert statement == statement.args + assert statement == 'command is done' assert statement.argv == ['multiline', 'command', 'is', 'done'] + assert statement.arg_list == statement.argv[1:] assert statement.terminator == ';' def test_parse_multiline_terminated_by_empty_line(parser): @@ -381,9 +381,9 @@ def test_parse_multiline_terminated_by_empty_line(parser): statement = parser.parse(line) assert statement.multiline_command == 'multiline' assert statement.command == 'multiline' - assert statement.args == 'command ends' - assert statement == statement.args + assert statement == 'command ends' assert statement.argv == ['multiline', 'command', 'ends'] + assert statement.arg_list == statement.argv[1:] assert statement.terminator == '\n' @pytest.mark.parametrize('line,terminator',[ @@ -398,9 +398,9 @@ def test_parse_multiline_with_embedded_newline(parser, line, terminator): statement = parser.parse(line) assert statement.multiline_command == 'multiline' assert statement.command == 'multiline' - assert statement.args == 'command "with\nembedded newline"' - assert statement == statement.args + assert statement == 'command "with\nembedded newline"' assert statement.argv == ['multiline', 'command', 'with\nembedded newline'] + assert statement.arg_list == ['command', '"with\nembedded newline"'] assert statement.terminator == terminator def test_parse_multiline_ignores_terminators_in_comments(parser): @@ -408,34 +408,34 @@ def test_parse_multiline_ignores_terminators_in_comments(parser): statement = parser.parse(line) assert statement.multiline_command == 'multiline' assert statement.command == 'multiline' - assert statement.args == 'command "with term; ends" now' - assert statement == statement.args + assert statement == 'command "with term; ends" now' assert statement.argv == ['multiline', 'command', 'with term; ends', 'now'] + assert statement.arg_list == ['command', '"with term; ends"', 'now'] assert statement.terminator == '\n' def test_parse_command_with_unicode_args(parser): line = 'drink café' statement = parser.parse(line) assert statement.command == 'drink' - assert statement.args == 'café' - assert statement == statement.args + assert statement == 'café' assert statement.argv == ['drink', 'café'] + assert statement.arg_list == statement.argv[1:] def test_parse_unicode_command(parser): line = 'café au lait' statement = parser.parse(line) assert statement.command == 'café' - assert statement.args == 'au lait' - assert statement == statement.args + assert statement == 'au lait' assert statement.argv == ['café', 'au', 'lait'] + assert statement.arg_list == statement.argv[1:] def test_parse_redirect_to_unicode_filename(parser): line = 'dir home > café' statement = parser.parse(line) assert statement.command == 'dir' - assert statement.args == 'home' - assert statement == statement.args + assert statement == 'home' assert statement.argv == ['dir', 'home'] + assert statement.arg_list == statement.argv[1:] assert statement.output == '>' assert statement.output_to == 'café' @@ -452,9 +452,9 @@ def test_empty_statement_raises_exception(): app._complete_statement(' ') @pytest.mark.parametrize('line,command,args', [ - ('helpalias', 'help', None), + ('helpalias', 'help', ''), ('helpalias mycommand', 'help', 'mycommand'), - ('42', 'theanswer', None), + ('42', 'theanswer', ''), ('42 arg1 arg2', 'theanswer', 'arg1 arg2'), ('!ls', 'shell', 'ls'), ('!ls -al /tmp', 'shell', 'ls -al /tmp'), @@ -463,20 +463,15 @@ def test_empty_statement_raises_exception(): def test_parse_alias_and_shortcut_expansion(parser, line, command, args): statement = parser.parse(line) assert statement.command == command - assert statement.args == args - if statement.args is None: - assert statement == '' - else: - assert statement == statement.args + assert statement == args def test_parse_alias_on_multiline_command(parser): line = 'anothermultiline has > inside an unfinished command' statement = parser.parse(line) assert statement.multiline_command == 'multiline' assert statement.command == 'multiline' - assert statement.args == 'has > inside an unfinished command' - assert statement == statement.args - assert not statement.terminator + assert statement == 'has > inside an unfinished command' + assert statement.terminator is None @pytest.mark.parametrize('line,output', [ ('helpalias > out.txt', '>'), @@ -487,7 +482,6 @@ def test_parse_alias_on_multiline_command(parser): def test_parse_alias_redirection(parser, line, output): statement = parser.parse(line) assert statement.command == 'help' - assert statement.args is None assert statement == '' assert statement.output == output assert statement.output_to == 'out.txt' @@ -499,7 +493,6 @@ def test_parse_alias_redirection(parser, line, output): def test_parse_alias_pipe(parser, line): statement = parser.parse(line) assert statement.command == 'help' - assert statement.args is None assert statement == '' assert statement.pipe_to == ['less'] @@ -514,7 +507,6 @@ def test_parse_alias_pipe(parser, line): def test_parse_alias_terminator_no_whitespace(parser, line): statement = parser.parse(line) assert statement.command == 'help' - assert statement.args is None assert statement == '' assert statement.terminator == ';' @@ -522,8 +514,7 @@ def test_parse_command_only_command_and_args(parser): line = 'help history' statement = parser.parse_command_only(line) assert statement.command == 'help' - assert statement.args == 'history' - assert statement == statement.args + assert statement == 'history' assert statement.command_and_args == line def test_parse_command_only_emptyline(parser): @@ -533,40 +524,36 @@ def test_parse_command_only_emptyline(parser): # should be '', to retain backwards compatibility with # the cmd in the standard library assert statement.command is None - assert statement.args is None assert statement == '' assert not statement.argv - assert statement.command_and_args == None + assert not statement.arg_list + assert statement.command_and_args is None def test_parse_command_only_strips_line(parser): line = ' help history ' statement = parser.parse_command_only(line) assert statement.command == 'help' - assert statement.args == 'history' - assert statement == statement.args + assert statement == 'history' assert statement.command_and_args == line.strip() def test_parse_command_only_expands_alias(parser): line = 'fake foobar.py' statement = parser.parse_command_only(line) assert statement.command == 'pyscript' - assert statement.args == 'foobar.py' - assert statement == statement.args + assert statement == 'foobar.py' def test_parse_command_only_expands_shortcuts(parser): line = '!cat foobar.txt' statement = parser.parse_command_only(line) assert statement.command == 'shell' - assert statement.args == 'cat foobar.txt' - assert statement == statement.args + assert statement == 'cat foobar.txt' assert statement.command_and_args == 'shell cat foobar.txt' def test_parse_command_only_quoted_args(parser): line = 'l "/tmp/directory with spaces/doit.sh"' statement = parser.parse_command_only(line) assert statement.command == 'shell' - assert statement.args == 'ls -al "/tmp/directory with spaces/doit.sh"' - assert statement == statement.args + assert statement == 'ls -al "/tmp/directory with spaces/doit.sh"' assert statement.command_and_args == line.replace('l', 'shell ls -al') @pytest.mark.parametrize('line', [ @@ -598,7 +585,6 @@ def test_parse_command_only_specialchars(parser, line): def test_parse_command_only_none(parser, line): statement = parser.parse_command_only(line) assert statement.command is None - assert statement.args is None assert statement == '' def test_parse_command_only_multiline(parser): @@ -606,8 +592,7 @@ def test_parse_command_only_multiline(parser): statement = parser.parse_command_only(line) assert statement.command == 'multiline' assert statement.multiline_command == 'multiline' - assert statement.args == 'with partially "open quotes and no terminator' - assert statement == statement.args + assert statement == 'with partially "open quotes and no terminator' assert statement.command_and_args == line @@ -617,7 +602,7 @@ def test_statement_initialization(parser): assert string == statement assert statement.raw is None assert statement.command is None - assert statement.args is None + assert isinstance(statement.arg_list, list) assert isinstance(statement.argv, list) assert not statement.argv assert statement.multiline_command is None -- cgit v1.2.1 From 2b2110ad24b64d022128051169a3515257922c8f Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 23 Aug 2018 23:36:42 -0400 Subject: Added way of returning a non-zero exit code to the shell --- CHANGELOG.md | 3 +++ cmd2/cmd2.py | 6 ++++++ docs/unfreefeatures.rst | 7 +++++++ examples/exit_code.py | 45 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+) create mode 100755 examples/exit_code.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e08e5a75..7fbc7594 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ ## 0.9.5 (TBD, 2018) * Bug Fixes * Fixed bug where ``get_all_commands`` could return non-callable attributes +* Enhancements + * Added ``exit_code`` attribute of ``cmd2.Cmd`` class + * Enables applications to return a non-zero exit code when exiting from ``cmdloop`` ## 0.9.4 (August 21, 2018) * Bug Fixes diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 58972232..a17e128f 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -523,6 +523,9 @@ 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 + # ----- Methods related to presenting output to the user ----- @property @@ -3227,6 +3230,9 @@ Script should contain one command per line, just like command would be typed in func() self.postloop() + if self.exit_code is not None: + sys.exit(self.exit_code) + ### # # plugin related functions diff --git a/docs/unfreefeatures.rst b/docs/unfreefeatures.rst index 41144c8f..cd27745d 100644 --- a/docs/unfreefeatures.rst +++ b/docs/unfreefeatures.rst @@ -182,3 +182,10 @@ Presents numbered options to user, as bash ``select``. 2. salty Sauce? 2 wheaties with salty sauce, yum! + + +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()``. diff --git a/examples/exit_code.py b/examples/exit_code.py new file mode 100755 index 00000000..e5a896da --- /dev/null +++ b/examples/exit_code.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python +# coding=utf-8 +"""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 + + +class ReplWithExitCode(cmd2.Cmd): + """ Example cmd2 application where we can specify an exit code when existing.""" + + def __init__(self): + super().__init__() + + @cmd2.with_argument_list + def do_exit(self, arg_list: List[str]) -> bool: + """Exit the application with an optional exit code. + +Usage: exit [exit_code] + Where: + * exit_code - integer exit code to return to the shell +""" + # If an argument was provided + if arg_list: + try: + self.exit_code = int(arg_list[0]) + except ValueError: + 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 + print('{!r} exiting with code: {}'.format(sys.argv[0], code)) + + +if __name__ == '__main__': + app = ReplWithExitCode() + app.cmdloop() -- cgit v1.2.1 From d567570faf490d052f3f74a65a6af6aa338d965a Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 23 Aug 2018 23:59:42 -0400 Subject: Added a couple unit tests of the exit code feature --- examples/exit_code.py | 6 ++--- tests/test_cmd2.py | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/examples/exit_code.py b/examples/exit_code.py index e5a896da..8ae2d310 100755 --- a/examples/exit_code.py +++ b/examples/exit_code.py @@ -33,11 +33,9 @@ Usage: exit [exit_code] return self._STOP_AND_EXIT def postloop(self) -> None: - """Hook method executed once when the cmdloop() method is about to return. - - """ + """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 - print('{!r} exiting with code: {}'.format(sys.argv[0], code)) + self.poutput('{!r} exiting with code: {}'.format(sys.argv[0], code)) if __name__ == '__main__': diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index d9ef5e78..b6081f53 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1930,3 +1930,78 @@ def test_get_help_topics(base_app): # Verify that the base app has no additional help_foo methods custom_help = base_app.get_help_topics() assert len(custom_help) == 0 + + +class ReplWithExitCode(cmd2.Cmd): + """ Example cmd2 application where we can specify an exit code when existing.""" + + def __init__(self): + super().__init__() + + @cmd2.with_argument_list + def do_exit(self, arg_list) -> bool: + """Exit the application with an optional exit code. + +Usage: exit [exit_code] + Where: + * exit_code - integer exit code to return to the shell +""" + # If an argument was provided + if arg_list: + try: + self.exit_code = int(arg_list[0]) + except ValueError: + 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('exiting with code: {}'.format(code)) + +@pytest.fixture +def exit_code_repl(): + app = ReplWithExitCode() + return app + +def test_exit_code_default(exit_code_repl): + # Create a cmd2.Cmd() instance and make sure basic settings are like we want for test + app = exit_code_repl + app.use_rawinput = True + app.stdout = StdOut() + + # Mock out the input call so we don't actually wait for a user's response on stdin + m = mock.MagicMock(name='input', return_value='exit') + builtins.input = m + + # Need to patch sys.argv so cmd2 doesn't think it was called with arguments equal to the py.test args + testargs = ["prog"] + expected = 'exiting with code: 0\n' + with mock.patch.object(sys, 'argv', testargs): + # Run the command loop + app.cmdloop() + out = app.stdout.buffer + assert out == expected + +def test_exit_code_nonzero(exit_code_repl): + # Create a cmd2.Cmd() instance and make sure basic settings are like we want for test + app = exit_code_repl + app.use_rawinput = True + app.stdout = StdOut() + + # Mock out the input call so we don't actually wait for a user's response on stdin + m = mock.MagicMock(name='input', return_value='exit 23') + builtins.input = m + + # Need to patch sys.argv so cmd2 doesn't think it was called with arguments equal to the py.test args + testargs = ["prog"] + expected = 'exiting with code: 23\n' + with mock.patch.object(sys, 'argv', testargs): + # Run the command loop + with pytest.raises(SystemExit): + app.cmdloop() + out = app.stdout.buffer + assert out == expected -- cgit v1.2.1 From d953fb28d9afc82098512b0bd5f99104a9c193b8 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 24 Aug 2018 11:10:03 -0400 Subject: ACHelpFormatter now inherits from argparse.RawTextHelpFormatter to make it easier to format help/description text --- cmd2/argparse_completer.py | 5 +---- cmd2/cmd2.py | 15 ++++++++------- examples/tab_autocompletion.py | 11 ++++------- tests/conftest.py | 8 ++++---- tests/test_autocompletion.py | 6 +++--- tests/test_cmd2.py | 8 +++----- 6 files changed, 23 insertions(+), 30 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 1479a6bf..0e241cd9 100755 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -695,7 +695,7 @@ class AutoCompleter(object): # noinspection PyCompatibility,PyShadowingBuiltins,PyShadowingBuiltins -class ACHelpFormatter(argparse.HelpFormatter): +class ACHelpFormatter(argparse.RawTextHelpFormatter): """Custom help formatter to configure ordering of help text""" def _format_usage(self, usage, actions, groups, prefix) -> str: @@ -870,9 +870,6 @@ class ACHelpFormatter(argparse.HelpFormatter): result = super()._format_args(action, default_metavar) return result - def _split_lines(self, text: str, width) -> List[str]: - return text.splitlines() - # noinspection PyCompatibility class ACArgumentParser(argparse.ArgumentParser): diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 58972232..2303e86c 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -2553,18 +2553,19 @@ Usage: Usage: unalias [-a] name [name ...] else: raise LookupError("Parameter '%s' not supported (type 'set' for list of parameters)." % param) - set_parser = ACArgumentParser(formatter_class=argparse.RawTextHelpFormatter) + set_description = "Sets a settable parameter or shows current settings of parameters.\n" + set_description += "\n" + set_description += "Accepts abbreviated parameter names so long as there is no ambiguity.\n" + set_description += "Call without arguments for a list of settable parameters with their values." + + set_parser = ACArgumentParser(description=set_description) set_parser.add_argument('-a', '--all', action='store_true', help='display read-only settings as well') set_parser.add_argument('-l', '--long', action='store_true', help='describe function of parameter') set_parser.add_argument('settable', nargs=(0, 2), help='[param_name] [value]') @with_argparser(set_parser) def do_set(self, args: argparse.Namespace) -> None: - """Sets a settable parameter or shows current settings of parameters. - - Accepts abbreviated parameter names so long as there is no ambiguity. - Call without arguments for a list of settable parameters with their values. - """ + """Sets a settable parameter or shows current settings of parameters""" try: param_name, val = args.settable val = val.strip() @@ -2879,7 +2880,7 @@ Paths or arguments that contain spaces must be enclosed in quotes embed(banner1=banner, exit_msg=exit_msg) load_ipy(bridge) - history_parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter) + history_parser = ACArgumentParser() history_parser_group = history_parser.add_mutually_exclusive_group() history_parser_group.add_argument('-r', '--run', action='store_true', help='run selected history items') history_parser_group.add_argument('-e', '--edit', action='store_true', diff --git a/examples/tab_autocompletion.py b/examples/tab_autocompletion.py index 38972358..6a2e683e 100755 --- a/examples/tab_autocompletion.py +++ b/examples/tab_autocompletion.py @@ -125,7 +125,9 @@ class TabCompleteExample(cmd2.Cmd): # - The help output for arguments with multiple flags or with append=True is more concise # - ACArgumentParser adds the ability to specify ranges of argument counts in 'nargs' - suggest_parser = argparse_completer.ACArgumentParser() + suggest_description = "Suggest command demonstrates argparse customizations.\n" + suggest_description += "See hybrid_suggest and orig_suggest to compare the help output." + suggest_parser = argparse_completer.ACArgumentParser(description=suggest_description) suggest_parser.add_argument('-t', '--type', choices=['movie', 'show'], required=True) suggest_parser.add_argument('-d', '--duration', nargs=(1, 2), action='append', @@ -136,12 +138,7 @@ class TabCompleteExample(cmd2.Cmd): @cmd2.with_category(CAT_AUTOCOMPLETE) @cmd2.with_argparser(suggest_parser) def do_suggest(self, args) -> None: - """Suggest command demonstrates argparse customizations - - See hybrid_suggest and orig_suggest to compare the help output. - - - """ + """Suggest command demonstrates argparse customizations""" if not args.type: self.do_help('suggest') diff --git a/tests/conftest.py b/tests/conftest.py index 3f3b862e..f86a4c63 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,14 +42,14 @@ load Runs commands in script file that is encoded as either ASCII py Invoke python command, shell, or script pyscript Runs a python script file inside the console quit Exits this application. -set Sets a settable parameter or shows current settings of parameters. +set Sets a settable parameter or shows current settings of parameters shell Execute a command as if at the OS prompt. shortcuts Lists shortcuts (aliases) available. unalias Unsets aliases """ # Help text for the history command -HELP_HISTORY = """usage: history [-h] [-r | -e | -s | -o FILE | -t TRANSCRIPT | -c] [arg] +HELP_HISTORY = """Usage: history [arg] [-h] [-r | -e | -s | -o FILE | -t TRANSCRIPT | -c] View, run, edit, save, or clear previously entered commands. @@ -65,9 +65,9 @@ optional arguments: -r, --run run selected history items -e, --edit edit and then run selected history items -s, --script script format; no separation lines - -o FILE, --output-file FILE + -o, --output-file FILE output commands to a script file - -t TRANSCRIPT, --transcript TRANSCRIPT + -t, --transcript TRANSCRIPT output commands and results to a transcript file -c, --clear clears all history """ diff --git a/tests/test_autocompletion.py b/tests/test_autocompletion.py index e0a71831..8aa26e0e 100644 --- a/tests/test_autocompletion.py +++ b/tests/test_autocompletion.py @@ -19,8 +19,8 @@ def cmd2_app(): SUGGEST_HELP = '''Usage: suggest -t {movie, show} [-h] [-d DURATION{1..2}] -Suggest command demonstrates argparse customizations See hybrid_suggest and -orig_suggest to compare the help output. +Suggest command demonstrates argparse customizations. +See hybrid_suggest and orig_suggest to compare the help output. required arguments: -t, --type {movie, show} @@ -59,7 +59,7 @@ def test_help_required_group(cmd2_app, capsys): assert out1 == out2 assert out1[0].startswith('Usage: suggest') assert out1[1] == '' - assert out1[2].startswith('Suggest command demonstrates argparse customizations ') + assert out1[2].startswith('Suggest command demonstrates argparse customizations.') assert out1 == normalize(SUGGEST_HELP) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index d9ef5e78..7f97a795 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -62,7 +62,7 @@ def test_base_argparse_help(base_app, capsys): out2 = run_cmd(base_app, 'help set') assert out1 == out2 - assert out1[0].startswith('usage: set') + assert out1[0].startswith('Usage: set') assert out1[1] == '' assert out1[2].startswith('Sets a settable parameter') @@ -71,10 +71,8 @@ def test_base_invalid_option(base_app, capsys): out, err = capsys.readouterr() out = normalize(out) err = normalize(err) - assert len(err) == 3 - assert len(out) == 15 assert 'Error: unrecognized arguments: -z' in err[0] - assert out[0] == 'usage: set [-h] [-a] [-l] [settable [settable ...]]' + assert out[0] == 'Usage: set settable{0..2} [-h] [-a] [-l]' def test_base_shortcuts(base_app): out = run_cmd(base_app, 'shortcuts') @@ -1252,7 +1250,7 @@ load Runs commands in script file that is encoded as either ASCII py Invoke python command, shell, or script pyscript Runs a python script file inside the console quit Exits this application. -set Sets a settable parameter or shows current settings of parameters. +set Sets a settable parameter or shows current settings of parameters shell Execute a command as if at the OS prompt. shortcuts Lists shortcuts (aliases) available. unalias Unsets aliases -- cgit v1.2.1 From a5d4c71072ecb9a4c07fb88a8694726eaf5400a7 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 24 Aug 2018 12:08:59 -0400 Subject: Added change to ACHelpFormatter --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fbc7594..9f201664 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ * Enhancements * Added ``exit_code`` attribute of ``cmd2.Cmd`` class * Enables applications to return a non-zero exit code when exiting from ``cmdloop`` + * ``ACHelpFormatter`` now inherits from ``argparse.RawTextHelpFormatter`` to make it easier + for formatting help/description text ## 0.9.4 (August 21, 2018) * Bug Fixes -- cgit v1.2.1 From 59c4ce802ac78c7f845250a8e0c10210b6283800 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 24 Aug 2018 12:49:50 -0400 Subject: Added a tag invoke task for adding a Git tag and pushing it to origin --- tasks.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tasks.py b/tasks.py index 583674e9..b045b81b 100644 --- a/tasks.py +++ b/tasks.py @@ -173,6 +173,15 @@ def clean_all(context): pass namespace_clean.add_task(clean_all, 'all') +@invoke.task +def tag(context, name='', message=''): + "Add a Git tag and push it to origin" + # If a tag was provided on the command-line, then add a Git tag and push it to origin + if name: + context.run('git tag -a {} -m {!r}'.format(name, message)) + context.run('git push origin {}'.format(name)) +namespace.add_task(tag) + @invoke.task(pre=[clean_all]) def sdist(context): "Create a source distribution" -- cgit v1.2.1 From ac33619741ef4cdbf4ddcda444ea0428d1348ef0 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 24 Aug 2018 13:20:09 -0400 Subject: Added a validatetag invoke task to check to make sure a Git tag exists for the current HEAD Also: - The pypi and pypi-test invoke tasks now have the validatetag task as their first prerequisite - So attempting to publish to pypi without a Git tag will fail --- tasks.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tasks.py b/tasks.py index b045b81b..c7b35407 100644 --- a/tasks.py +++ b/tasks.py @@ -182,6 +182,13 @@ def tag(context, name='', message=''): context.run('git push origin {}'.format(name)) namespace.add_task(tag) +@invoke.task() +def validatetag(context): + "Check to make sure that a tag exists for the current HEAD" + # Validate that a Git tag exists for the current commit HEAD + context.run("git describe --exact-match --tags $(git log -n1 --pretty='%h')") +namespace.add_task(validatetag) + @invoke.task(pre=[clean_all]) def sdist(context): "Create a source distribution" @@ -194,13 +201,13 @@ def wheel(context): context.run('python setup.py bdist_wheel') namespace.add_task(wheel) -@invoke.task(pre=[sdist, wheel]) +@invoke.task(pre=[validatetag, sdist, wheel]) def pypi(context): "Build and upload a distribution to pypi" context.run('twine upload dist/*') namespace.add_task(pypi) -@invoke.task(pre=[sdist, wheel]) +@invoke.task(pre=[validatetag, sdist, wheel]) def pypi_test(context): "Build and upload a distribution to https://test.pypi.org" context.run('twine upload --repository-url https://test.pypi.org/legacy/ dist/*') -- cgit v1.2.1 From 3aec9d82bd0c9ab330540abd153b963884b20db9 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Mon, 27 Aug 2018 23:40:42 -0400 Subject: Added regex to validatetag invoke task to check to make sure Git Tag appears to be a version number --- tasks.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/tasks.py b/tasks.py index c7b35407..d6dc43c9 100644 --- a/tasks.py +++ b/tasks.py @@ -8,7 +8,9 @@ Make sure you satisfy the following Python module requirements if you are trying - setuptools >= 39.1.0 """ import os +import re import shutil +import sys import invoke @@ -184,9 +186,21 @@ namespace.add_task(tag) @invoke.task() def validatetag(context): - "Check to make sure that a tag exists for the current HEAD" + "Check to make sure that a tag exists for the current HEAD and it looks like a valid version number" # Validate that a Git tag exists for the current commit HEAD - context.run("git describe --exact-match --tags $(git log -n1 --pretty='%h')") + result = context.run("git describe --exact-match --tags $(git log -n1 --pretty='%h')") + tag = result.stdout.rstrip() + + # Validate that the Git tag appears to be a valid version number + ver_regex = re.compile('(\d+)\.(\d+)\.(\d+)') + match = ver_regex.fullmatch(tag) + if match is None: + print('Tag {!r} does not appear to be a valid version number'.format(tag)) + sys.exit(-1) + else: + print('Tag {!r} appears to be a valid version number'.format(tag)) + + namespace.add_task(validatetag) @invoke.task(pre=[clean_all]) -- cgit v1.2.1