diff options
author | Todd Leonhardt <todd.leonhardt@gmail.com> | 2018-09-12 22:49:34 -0400 |
---|---|---|
committer | Todd Leonhardt <todd.leonhardt@gmail.com> | 2018-09-12 22:49:34 -0400 |
commit | 2d82b8f49d212b06c33ed06fb9db5465aa7a8b95 (patch) | |
tree | 8fe9f3032142efb84da82e8a0285fd38b26fbdfe | |
parent | 52d76c18246e70a5311b94c452c236524d64586e (diff) | |
parent | 49236d98a770d9604e65eb1728d2f8d68e35d493 (diff) | |
download | cmd2-git-2d82b8f49d212b06c33ed06fb9db5465aa7a8b95.tar.gz |
Merged master into colorize branch
-rw-r--r-- | CHANGELOG.md | 3 | ||||
-rwxr-xr-x | README.md | 6 | ||||
-rw-r--r-- | azure-pipelines.yml | 56 | ||||
-rw-r--r-- | cmd2/cmd2.py | 140 | ||||
-rw-r--r-- | cmd2/parsing.py | 278 | ||||
-rw-r--r-- | docs/argument_processing.rst | 43 | ||||
-rwxr-xr-x | setup.py | 15 | ||||
-rw-r--r-- | tests/conftest.py | 16 | ||||
-rw-r--r-- | tests/test_cmd2.py | 21 | ||||
-rw-r--r-- | tests/test_parsing.py | 398 | ||||
-rw-r--r-- | tox.ini | 87 |
11 files changed, 628 insertions, 435 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f201664..8d49d34e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ -## 0.9.5 (TBD, 2018) +## 0.9.5 (September TBD, 2018) * Bug Fixes * Fixed bug where ``get_all_commands`` could return non-callable attributes + * Fixed bug where **alias** command was dropping quotes around arguments * Enhancements * Added ``exit_code`` attribute of ``cmd2.Cmd`` class * Enables applications to return a non-zero exit code when exiting from ``cmdloop`` @@ -3,7 +3,7 @@ cmd2: a tool for building interactive command line apps [](https://pypi.python.org/pypi/cmd2/) [](https://travis-ci.org/python-cmd2/cmd2) [](https://ci.appveyor.com/project/FedericoCeratto/cmd2) -[](https://python-cmd2.visualstudio.com/cmd2/_build/latest?definitionId=1&branch=master) +[](https://python-cmd2.visualstudio.com/cmd2/_build/latest?definitionId=1&branch=master) [](https://codecov.io/gh/python-cmd2/cmd2) [](http://cmd2.readthedocs.io/en/latest/?badge=latest) @@ -43,9 +43,9 @@ Main Features Python 2.7 support is EOL ------------------------- -Support for adding new features to the Python 2.7 release of ``cmd2`` was discontinued on April 15, 2018. Bug fixes will be supported for Python 2.7 via 0.8.x until August 31, 2018. +The last version of cmd2 to support Python 2.7 is [0.8.9](https://pypi.org/project/cmd2/0.8.9/), released on August 21, 2018. -Supporting Python 2 was an increasing burden on our limited resources. Switching to support only Python 3 will allow +Supporting Python 2 was an increasing burden on our limited resources. Switching to support only Python 3 is allowing us to clean up the codebase, remove some cruft, and focus on developing new features. Installation diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 00000000..374c6b3b --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,56 @@ +# Python package +# Create and test a Python package on multiple Python versions. +# Add steps that analyze code, save the dist with the build record, publish to a PyPI-compatible index, and more: +# https://docs.microsoft.com/vsts/pipelines/languages/python + +jobs: + +- job: 'Test' + + # Configure Build Environment to use Azure Pipelines to build Python project using macOS + pool: + vmImage: 'macOS 10.13' # other options 'Ubuntu 16.04', 'VS2017-Win2016' + + # Run the pipeline with multiple Python versions + strategy: + matrix: + Python34: + python.version: '3.4' + Python35: + python.version: '3.5' + Python36: + python.version: '3.6' + Python37: + python.version: '3.7' + # Increase the maxParallel value to simultaneously run the job for all versions in the matrix (max 10 for free open-source) + maxParallel: 4 + + steps: + # Set the UsePythonVersion task to reference the matrix variable for its Python version + - task: UsePythonVersion@0 + inputs: + versionSpec: '$(python.version)' + architecture: 'x64' + + # Install dependencies - install specific PyPI packages with pip, including cmd2 dependencies + - script: | + python -m pip install --upgrade pip && pip3 install --upgrade setuptools gnureadline + pip install -e . + displayName: 'Upgrade pip and setuptools' + continueOnError: false + + # TODO: Consider adding a lint test to use pycodestyle, flake8, or pylint, to check code style conventions + + # Test - test with pytest, collect coverage metrics with pytest-cov, and publish these metrics to codecov.io + - script: | + pip install pytest pytest-cov pytest-mock codecov mock + py.test tests --cov --junitxml=junit/test-results.xml && codecov + displayName: 'Run tests and code coverage' + continueOnError: false + + # Publish test results to the Azure DevOps server + - task: PublishTestResults@2 + inputs: + testResultsFiles: '**/test-*.xml' + testRunTitle: 'Python $(python.version)' + condition: succeededOrFailed() diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 37b0d2d9..136328b1 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -629,7 +629,7 @@ class Cmd(cmd.Cmd): - truncated text is still accessible by scrolling with the right & left arrow keys - chopping is ideal for displaying wide tabular data as is done in utilities like pgcli False -> causes lines longer than the screen width to wrap to the next line - - wrapping is ideal when you want to avoid users having to use horizontal scrolling + - wrapping is ideal when you want to keep users from having to use horizontal scrolling WARNING: On Windows, the text always wraps regardless of what the chop argument is set to """ @@ -709,8 +709,7 @@ class Cmd(cmd.Cmd): elif rl_type == RlType.PYREADLINE: readline.rl.mode._display_completions = self._display_matches_pyreadline - def tokens_for_completion(self, line: str, begidx: int, endidx: int) -> Tuple[Optional[List[str]], - Optional[List[str]]]: + def tokens_for_completion(self, line: str, begidx: int, endidx: int) -> Tuple[List[str], List[str]]: """ Used by tab completion functions to get all tokens through the one being completed :param line: the current input line with leading whitespace removed @@ -727,7 +726,7 @@ class Cmd(cmd.Cmd): The last item in both lists is the token being tab completed On Failure - Both items are None + Two empty lists """ import copy unclosed_quote = '' @@ -747,21 +746,21 @@ class Cmd(cmd.Cmd): if not unclosed_quote and begidx == tmp_endidx: initial_tokens.append('') break - except ValueError: - # ValueError can be caused by missing closing quote - if not quotes_to_try: - # Since we have no more quotes to try, something else - # is causing the parsing error. Return None since - # this means the line is malformed. - return None, None - - # Add a closing quote and try to parse again - unclosed_quote = quotes_to_try[0] - quotes_to_try = quotes_to_try[1:] - - tmp_line = line[:endidx] - tmp_line += unclosed_quote - tmp_endidx = endidx + 1 + except ValueError as ex: + # Make sure the exception was due to an unclosed quote and + # we haven't exhausted the closing quotes to try + if str(ex) == "No closing quotation" and quotes_to_try: + # Add a closing quote and try to parse again + unclosed_quote = quotes_to_try[0] + quotes_to_try = quotes_to_try[1:] + + tmp_line = line[:endidx] + tmp_line += unclosed_quote + tmp_endidx = endidx + 1 + else: + # The parsing error is not caused by unclosed quotes. + # Return empty lists since this means the line is malformed. + return [], [] if self.allow_redirection: @@ -927,7 +926,7 @@ class Cmd(cmd.Cmd): """ # Get all tokens through the one being completed tokens, _ = self.tokens_for_completion(line, begidx, endidx) - if tokens is None: + if not tokens: return [] completions_matches = [] @@ -970,7 +969,7 @@ class Cmd(cmd.Cmd): """ # Get all tokens through the one being completed tokens, _ = self.tokens_for_completion(line, begidx, endidx) - if tokens is None: + if not tokens: return [] matches = [] @@ -1207,7 +1206,7 @@ class Cmd(cmd.Cmd): # Get all tokens through the one being completed. We want the raw tokens # so we can tell if redirection strings are quoted and ignore them. _, raw_tokens = self.tokens_for_completion(line, begidx, endidx) - if raw_tokens is None: + if not raw_tokens: return [] if len(raw_tokens) > 1: @@ -1415,9 +1414,9 @@ class Cmd(cmd.Cmd): # Get all tokens through the one being completed tokens, raw_tokens = self.tokens_for_completion(line, begidx, endidx) - # Either had a parsing error or are trying to complete the command token + # Check if we either had a parsing error or are trying to complete the command token # The latter can happen if " or ' was entered as the command - if tokens is None or len(tokens) == 1: + if len(tokens) <= 1: self.completion_matches = [] return None @@ -1567,9 +1566,10 @@ class Cmd(cmd.Cmd): completer = AutoCompleter(argparser, cmd2_app=self) tokens, _ = self.tokens_for_completion(line, begidx, endidx) - results = completer.complete_command(tokens, text, line, begidx, endidx) + if not tokens: + return [] - return results + return completer.complete_command(tokens, text, line, begidx, endidx) def get_all_commands(self) -> List[str]: """Returns a list of all commands.""" @@ -1606,7 +1606,7 @@ class Cmd(cmd.Cmd): # Get all tokens through the one being completed tokens, _ = self.tokens_for_completion(line, begidx, endidx) - if tokens is None: + if not tokens: return [] matches = [] @@ -1718,12 +1718,6 @@ class Cmd(cmd.Cmd): :param stop: bool - True implies the entire application should exit. :return: bool - True implies the entire application should exit. """ - if not sys.platform.startswith('win'): - # Fix those annoying problems that occur with terminal programs like "less" when you pipe to them - if self.stdin.isatty(): - import subprocess - proc = subprocess.Popen(shlex.split('stty sane')) - proc.communicate() return stop def parseline(self, line: str) -> Tuple[str, str, str]: @@ -1818,6 +1812,14 @@ class Cmd(cmd.Cmd): def _run_cmdfinalization_hooks(self, stop: bool, statement: Optional[Statement]) -> bool: """Run the command finalization hooks""" + + if not sys.platform.startswith('win'): + # Fix those annoying problems that occur with terminal programs like "less" when you pipe to them + if self.stdin.isatty(): + import subprocess + proc = subprocess.Popen(shlex.split('stty sane')) + proc.communicate() + try: data = plugin.CommandFinalizationData(stop, statement) for func in self._cmdfinalization_hooks: @@ -2230,8 +2232,7 @@ class Cmd(cmd.Cmd): return stop - @with_argument_list - def do_alias(self, arglist: List[str]) -> None: + def do_alias(self, statement: Statement) -> None: """Define or display aliases Usage: Usage: alias [name] | [<name> <value>] @@ -2250,22 +2251,27 @@ Usage: Usage: alias [name] | [<name> <value>] Example: alias ls !ls -lF - If you want to use redirection or pipes in the alias, then either quote the tokens with these - characters or quote the entire alias value. + If you want to use redirection or pipes in the alias, then quote them to prevent + the alias command itself from being redirected Examples: alias save_results print_results ">" out.txt - alias save_results print_results "> out.txt" - alias save_results "print_results > out.txt" + alias save_results print_results '>' out.txt """ + # Get alias arguments as a list with quotes preserved + alias_arg_list = statement.arg_list + # If no args were given, then print a list of current aliases - if not arglist: + if not alias_arg_list: for cur_alias in self.aliases: self.poutput("alias {} {}".format(cur_alias, self.aliases[cur_alias])) + return + + # Get the alias name + name = alias_arg_list[0] # The user is looking up an alias - elif len(arglist) == 1: - name = arglist[0] + if len(alias_arg_list) == 1: if name in self.aliases: self.poutput("alias {} {}".format(name, self.aliases[name])) else: @@ -2273,8 +2279,16 @@ Usage: Usage: alias [name] | [<name> <value>] # The user is creating an alias else: - name = arglist[0] - value = ' '.join(arglist[1:]) + # Unquote redirection and pipes + index = 1 + while index < len(alias_arg_list): + unquoted_arg = utils.strip_quotes(alias_arg_list[index]) + if unquoted_arg in constants.REDIRECTION_TOKENS: + alias_arg_list[index] = unquoted_arg + index += 1 + + # Build the alias value string + value = ' '.join(alias_arg_list[1:]) # Validate the alias to ensure it doesn't include weird characters # like terminators, output redirection, or whitespace @@ -2334,7 +2348,7 @@ Usage: Usage: unalias [-a] name [name ...] @with_argument_list def do_help(self, arglist: List[str]) -> None: - """List available commands with "help" or detailed help with "help cmd".""" + """ List available commands with "help" or detailed help with "help cmd" """ if not arglist or (len(arglist) == 1 and arglist[0] in ('--verbose', '-v')): verbose = len(arglist) == 1 and arglist[0] in ('--verbose', '-v') self._help_menu(verbose) @@ -2473,22 +2487,22 @@ Usage: Usage: unalias [-a] name [name ...] self.stdout.write("\n") def do_shortcuts(self, _: str) -> None: - """Lists shortcuts (aliases) available.""" + """Lists shortcuts available""" result = "\n".join('%s: %s' % (sc[0], sc[1]) for sc in sorted(self.shortcuts)) self.poutput("Shortcuts for other commands:\n{}\n".format(result)) def do_eof(self, _: str) -> bool: - """Called when <Ctrl>-D is pressed.""" + """Called when <Ctrl>-D is pressed""" # End of script should not exit app, but <Ctrl>-D should. return self._STOP_AND_EXIT def do_quit(self, _: str) -> bool: - """Exits this application.""" + """Exits this application""" self._should_quit = True return self._STOP_AND_EXIT def select(self, opts: Union[str, List[str], List[Tuple[Any, Optional[str]]]], prompt: str='Your choice? ') -> str: - """Presents a numbered menu to the user. Modelled after + """Presents a numbered menu to the user. Modeled after the bash shell's SELECT. Returns the item chosen. Argument ``opts`` can be: @@ -2611,24 +2625,20 @@ Usage: Usage: unalias [-a] name [name ...] param = args.settable[0] self.show(args, param) - def do_shell(self, command: str) -> None: - """Execute a command as if at the OS prompt. + def do_shell(self, statement: Statement) -> None: + """Execute a command as if at the OS prompt Usage: shell <command> [arguments]""" - import subprocess - try: - # Use non-POSIX parsing to keep the quotes around the tokens - tokens = shlex.split(command, posix=False) - except ValueError as err: - self.perror(err, traceback_war=False) - return + + # Get list of arguments to shell with quotes preserved + tokens = statement.arg_list # Support expanding ~ in quoted paths for index, _ in enumerate(tokens): if tokens[index]: - # Check if the token is quoted. Since shlex.split() passed, there isn't - # an unclosed quote, so we only need to check the first character. + # Check if the token is quoted. Since parsing already passed, there isn't + # an unclosed quote. So we only need to check the first character. first_char = tokens[index][0] if first_char in constants.QUOTES: tokens[index] = utils.strip_quotes(tokens[index]) @@ -2914,7 +2924,7 @@ 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, save, or clear previously entered commands.""" + """View, run, edit, save, or clear previously entered commands""" if args.clear: # Clear command and readline history @@ -3058,7 +3068,7 @@ a..b, a:b, a:, ..b items by indices (inclusive) @with_argument_list def do_edit(self, arglist: List[str]) -> None: - """Edit a file in a text editor. + """Edit a file in a text editor Usage: edit [file_path] Where: @@ -3090,7 +3100,7 @@ The editor used is determined by the ``editor`` settable parameter. @with_argument_list def do__relative_load(self, arglist: List[str]) -> None: - """Runs commands in script file that is encoded as either ASCII or UTF-8 text. + """Runs commands in script file that is encoded as either ASCII or UTF-8 text Usage: _relative_load <file_path> @@ -3115,13 +3125,13 @@ NOTE: This command is intended to only be used within text file scripts. self.do_load([relative_path]) def do_eos(self, _: str) -> None: - """Handles cleanup when a script has finished executing.""" + """Handles cleanup when a script has finished executing""" if self._script_dir: self._script_dir.pop() @with_argument_list def do_load(self, arglist: List[str]) -> None: - """Runs commands in script file that is encoded as either ASCII or UTF-8 text. + """Runs commands in script file that is encoded as either ASCII or UTF-8 text Usage: load <file_path> diff --git a/cmd2/parsing.py b/cmd2/parsing.py index b67cef10..8edfacb9 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -7,10 +7,13 @@ import re import shlex from typing import List, Tuple, Dict +import attr + from . import constants from . import utils +@attr.s(frozen=True) class Statement(str): """String subclass with additional attributes to store the results of parsing. @@ -26,98 +29,137 @@ class Statement(str): The string portion of the class contains the arguments, but not the command, nor the output redirection clauses. - :var raw: string containing exactly what we input by the user - :type raw: str - :var command: the command, i.e. the first whitespace delimited word - :type command: str or None - :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 - redirection or terminators. quoted arguments remain - quoted. - :type args: str or None - :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 - :type argv: list - :var terminator: the character which terminated the multiline command, if - there was one - :type terminator: str or None - :var suffix: characters appearing after the terminator but before output - redirection, if any - :type suffix: str or None - :var pipe_to: if output was piped to a shell command, the shell command - as a list of tokens - :type pipe_to: list - :var output: if output was redirected, the redirection token, i.e. '>>' - :type output: str or None - :var output_to: if output was redirected, the destination file - :type output_to: str or None - + Here's some suggestions and best practices for how to use the attributes of this + object: + + command - the name of the command, shortcuts and aliases have already been + expanded + + args - the arguments to the command, excluding output redirection and command + terminators. If the user used quotes in their input, they remain here, + and you will have to handle them on your own. + + arg_list - the arguments to the command, excluding output redirection and + command terminators. Each argument is represented as an element + in the list. Quoted arguments remain quoted. If you want to + remove the quotes, use `cmd2.utils.strip_quotes()` or use + `argv[1:]` + + command_and_args - join the args and the command together with a space. Output + redirection is excluded. + + argv - this is a list of arguments in the style of `sys.argv`. The first element + of the list is the command. Subsequent elements of the list contain any + additional arguments, with quotes removed, just like bash would. This + is very useful if you are going to use `argparse.parse_args()`: + ``` + def do_mycommand(stmt): + mycommand_argparser.parse_args(stmt.argv) + ... + ``` + + raw - if you want full access to exactly what the user typed at the input prompt + you can get it, but you'll have to parse it on your own, including: + - shortcuts and aliases + - quoted commands and arguments + - output redirection + - multi-line command terminator handling + if you use multiline commands, all the input will be passed to you in + this string, but there will be embedded newlines where + the user hit return to continue the command on the next line. + + Tips: + + 1. `argparse` is your friend for anything complex. `cmd2` has two decorators + (`with_argparser`, and `with_argparser_and_unknown_args`) which you can use + to make your command method receive a namespace of parsed arguments, whether + positional or denoted with switches. + + 2. For commands with simple positional arguments, use `args` or `arg_list` + + 3. If you don't want to have to worry about quoted arguments, use + argv[1:], which strips them all off for you. """ - def __new__(cls, - obj: object, - *, - raw: str = None, - command: str = None, - args: str = None, - argv: List[str] = None, - multiline_command: str = None, - terminator: str = None, - suffix: str = None, - pipe_to: str = None, - output: str = None, - output_to: str = None - ): - """Create a new instance of Statement + # the arguments, but not the command, nor the output redirection clauses. + args = attr.ib(default='', validator=attr.validators.instance_of(str), type=str) + + # string containing exactly what we input by the user + raw = attr.ib(default='', validator=attr.validators.instance_of(str), type=str) + + # the command, i.e. the first whitespace delimited word + command = attr.ib(default='', validator=attr.validators.instance_of(str), type=str) + + # list of arguments to the command, not including any output redirection or terminators; quoted args remain quoted + arg_list = attr.ib(factory=list, validator=attr.validators.instance_of(list), type=List[str]) + + # if the command is a multiline command, the name of the command, otherwise empty + multiline_command = attr.ib(default='', validator=attr.validators.instance_of(str), type=str) + + # the character which terminated the multiline command, if there was one + terminator = attr.ib(default='', validator=attr.validators.instance_of(str), type=str) + + # characters appearing after the terminator but before output redirection, if any + suffix = attr.ib(default='', validator=attr.validators.instance_of(str), type=str) + + # if output was piped to a shell command, the shell command as a list of tokens + pipe_to = attr.ib(factory=list, validator=attr.validators.instance_of(list), type=List[str]) + + # if output was redirected, the redirection token, i.e. '>>' + output = attr.ib(default='', validator=attr.validators.instance_of(str), type=str) + + # if output was redirected, the destination file + output_to = attr.ib(default='', validator=attr.validators.instance_of(str), type=str) + + def __new__(cls, value: object, *pos_args, **kw_args): + """Create a new instance of Statement. We must override __new__ because we are subclassing `str` which is - immutable. + immutable and takes a different number of arguments as Statement. + + NOTE: attrs takes care of initializing other members in the __init__ it + generates. """ - stmt = str.__new__(cls, obj) - object.__setattr__(stmt, "raw", raw) - object.__setattr__(stmt, "command", command) - object.__setattr__(stmt, "args", args) - if argv is None: - argv = [] - object.__setattr__(stmt, "argv", argv) - object.__setattr__(stmt, "multiline_command", multiline_command) - object.__setattr__(stmt, "terminator", terminator) - object.__setattr__(stmt, "suffix", suffix) - object.__setattr__(stmt, "pipe_to", pipe_to) - object.__setattr__(stmt, "output", output) - object.__setattr__(stmt, "output_to", output_to) + stmt = super().__new__(cls, value) return stmt @property - def command_and_args(self): + def command_and_args(self) -> str: """Combine command and args with a space separating them. - Quoted arguments remain quoted. + Quoted arguments remain quoted. Output redirection and piping are + excluded, as are any multiline command terminators. """ if self.command and self.args: rtn = '{} {}'.format(self.command, self.args) 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 + rtn = '' return rtn - def __setattr__(self, name, value): - """Statement instances should feel immutable; raise ValueError""" - raise ValueError + @property + def argv(self) -> List[str]: + """a list of arguments a la sys.argv. - def __delattr__(self, name): - """Statement instances should feel immutable; raise ValueError""" - raise ValueError + Quotes, if any, are removed from the elements of the list, and aliases + and shortcuts are expanded + """ + if self.command: + rtn = [utils.strip_quotes(self.command)] + for cur_token in self.arg_list: + rtn.append(utils.strip_quotes(cur_token)) + else: + rtn = [] + + return rtn class StatementParser: """Parse raw text into command components. - Shortcuts is a list of tuples with each tuple containing the shortcut and the expansion. + Shortcuts is a list of tuples with each tuple containing the shortcut and + the expansion. """ def __init__( self, @@ -231,7 +273,7 @@ class StatementParser: if match: if word == match.group(1): valid = True - errmsg = None + errmsg = '' return valid, errmsg def tokenize(self, line: str) -> List[str]: @@ -268,13 +310,13 @@ class StatementParser: # handle the special case/hardcoded terminator of a blank line # we have to do this before we tokenize because tokenizing # destroys all unquoted whitespace in the input - terminator = None + terminator = '' if line[-1:] == constants.LINE_FEED: terminator = constants.LINE_FEED - command = None - args = None - argv = None + command = '' + args = '' + arg_list = [] # lex the input into a list of tokens tokens = self.tokenize(line) @@ -302,8 +344,8 @@ class StatementParser: terminator_pos = len(tokens)+1 # everything before the first terminator is the command and the args - argv = tokens[:terminator_pos] - (command, args) = self._command_and_args(argv) + (command, args) = self._command_and_args(tokens[:terminator_pos]) + arg_list = tokens[1:terminator_pos] # we will set the suffix later # remove all the tokens before and including the terminator tokens = tokens[terminator_pos+1:] @@ -315,7 +357,7 @@ class StatementParser: # because redirectors can only be after a terminator command = testcommand args = testargs - argv = tokens + arg_list = tokens[1:] tokens = [] # check for a pipe to a shell process @@ -336,11 +378,11 @@ class StatementParser: tokens = tokens[:pipe_pos] except ValueError: # no pipe in the tokens - pipe_to = None + pipe_to = [] # check for output redirect - output = None - output_to = None + output = '' + output_to = '' try: output_pos = tokens.index(constants.REDIRECTION_OUTPUT) output = constants.REDIRECTION_OUTPUT @@ -374,26 +416,23 @@ class StatementParser: suffix = ' '.join(tokens) else: # no terminator, so whatever is left is the command and the args - suffix = None + suffix = '' if not command: # command could already have been set, if so, don't set it again - argv = tokens - (command, args) = self._command_and_args(argv) + (command, args) = self._command_and_args(tokens) + arg_list = tokens[1:] # set multiline if command in self.multiline_commands: multiline_command = command else: - multiline_command = None + multiline_command = '' # build the statement - # string representation of args must be an empty string instead of - # None for compatibility with standard library cmd - statement = Statement('' if args is None else args, + statement = Statement(args, raw=line, command=command, - args=args, - argv=list(map(lambda x: utils.strip_quotes(x), argv)), + arg_list=arg_list, multiline_command=multiline_command, terminator=terminator, suffix=suffix, @@ -413,53 +452,50 @@ class StatementParser: This method is used by tab completion code and therefore must not generate an exception if there are unclosed quotes. - The Statement object returned by this method can at most contained - values in the following attributes: + The `Statement` object returned by this method can at most contain values + in the following attributes: + - args - raw - command - - args + - multiline_command + + `Statement.args` includes all output redirection clauses and command + terminators. Different from parse(), this method does not remove redundant whitespace - within statement.args. It does however, ensure args does not have - leading or trailing whitespace. + within args. However, it does ensure args has no leading or trailing + whitespace. """ # expand shortcuts and aliases line = self._expand(rawinput) - command = None - args = None + command = '' + args = '' match = self._command_pattern.search(line) if match: # we got a match, extract the command command = match.group(1) - # the match could be an empty string, if so, turn it into none - if not command: - command = None - # the _command_pattern regex is designed to match the spaces - # between command and args with a second match group. Using - # the end of the second match group ensures that args has - # no leading whitespace. The rstrip() makes sure there is - # 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 + + # take everything from the end of the first match group to + # the end of the line as the arguments (stripping leading + # and trailing spaces) + args = line[match.end(1):].strip() + # if the command is empty that means the input was either empty + # or something weird like '>'. args should be empty if we couldn't # parse a command if not command or not args: - args = None + args = '' # set multiline if command in self.multiline_commands: multiline_command = command else: - multiline_command = None + multiline_command = '' # build the statement - # string representation of args must be an empty string instead of - # None for compatibility with standard library cmd - statement = Statement('' if args is None else args, + statement = Statement(args, raw=rawinput, command=command, - args=args, multiline_command=multiline_command, ) return statement @@ -503,12 +539,9 @@ class StatementParser: def _command_and_args(tokens: List[str]) -> Tuple[str, str]: """Given a list of tokens, return a tuple of the command and the args as a string. - - The args string will be '' instead of None to retain backwards compatibility - with cmd in the standard library. """ - command = None - args = None + command = '' + args = '' if tokens: command = tokens[0] @@ -528,10 +561,11 @@ class StatementParser: return matched_string def _split_on_punctuation(self, tokens: List[str]) -> List[str]: - """ - # Further splits tokens from a command line using punctuation characters - # as word breaks when they are in unquoted strings. Each run of punctuation - # characters is treated as a single token. + """Further splits tokens from a command line using punctuation characters + + Punctuation characters are treated as word breaks when they are in + unquoted strings. Each run of punctuation characters is treated as a + single token. :param tokens: the tokens as parsed by shlex :return: the punctuated tokens diff --git a/docs/argument_processing.rst b/docs/argument_processing.rst index 5aef3720..8aed7498 100644 --- a/docs/argument_processing.rst +++ b/docs/argument_processing.rst @@ -278,16 +278,16 @@ the help categories with per-command Help Messages:: ================================================================================ alias Define or display aliases config Config command - edit Edit a file in a text editor. - help List available commands with "help" or detailed help with "help cmd". + edit Edit a file in a text editor + help List available commands with "help" or detailed help with "help cmd" history usage: history [-h] [-r | -e | -s | -o FILE | -t TRANSCRIPT] [arg] - load Runs commands in script file that is encoded as either ASCII or UTF-8 text. + 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 - quit Exits this application. + quit Exits this application set usage: set [-h] [-a] [-l] [settable [settable ...]] - shell Execute a command as if at the OS prompt. - shortcuts Lists shortcuts (aliases) available. + shell Execute a command as if at the OS prompt + shortcuts Lists shortcuts available unalias Unsets aliases version Version command @@ -296,7 +296,36 @@ Receiving an argument list ========================== The default behavior of ``cmd2`` is to pass the user input directly to your -``do_*`` methods as a string. If you don't want to use the full argument parser support outlined above, you can still have ``cmd2`` apply shell parsing rules to the user input and pass you a list of arguments instead of a string. Apply the ``@with_argument_list`` decorator to those methods that should receive an argument list instead of a string:: +``do_*`` methods as a string. The object passed to your method is actually a +``Statement`` object, which has additional attributes that may be helpful, +including ``arg_list`` and ``argv``:: + + class CmdLineApp(cmd2.Cmd): + """ Example cmd2 application. """ + + def do_say(self, statement): + # statement contains a string + self.poutput(statement) + + def do_speak(self, statement): + # statement also has a list of arguments + # quoted arguments remain quoted + for arg in statement.arg_list: + self.poutput(arg) + + def do_articulate(self, statement): + # statement.argv contains the command + # and the arguments, which have had quotes + # stripped + for arg in statement.argv: + self.poutput(arg) + + +If you don't want to access the additional attributes on the string passed to +you``do_*`` method you can still have ``cmd2`` apply shell parsing rules to the +user input and pass you a list of arguments instead of a string. Apply the +``@with_argument_list`` decorator to those methods that should receive an +argument list instead of a string:: from cmd2 import with_argument_list @@ -70,13 +70,14 @@ EXTRAS_REQUIRE = { ":sys_platform!='win32'": ['wcwidth'], # Python 3.4 and earlier require contextlib2 for temporarily redirecting stderr and stdout ":python_version<'3.5'": ['contextlib2', 'typing'], - # development only dependencies - # install with 'pip install -e .[dev]' - 'dev': [ - # for python 3.5 and earlier we need the third party mock module - "mock ; python_version<'3.6'", - 'pytest', 'codecov', 'pytest-cov', 'pytest-mock', 'tox', 'pylint', - 'sphinx<1.7.7', 'sphinx-rtd-theme', 'sphinx-autobuild', 'invoke', 'twine>=1.11', + # Extra dependencies for running unit tests + 'test': ["argcomplete ; sys_platform!='win32'", # include argcomplete tests where available + "mock ; python_version<'3.6'", # for python 3.5 and earlier we need the third party mock module + 'codecov', 'pytest', 'pytest-cov', 'pytest-mock'], + # development only dependencies: install with 'pip install -e .[dev]' + 'dev': ["mock ; python_version<'3.6'", # for python 3.5 and earlier we need the third party mock module + 'pytest', 'codecov', 'pytest-cov', 'pytest-mock', 'tox', 'pylint', + 'sphinx', 'sphinx-rtd-theme', 'sphinx-autobuild', 'invoke', 'twine>=1.11', ] } diff --git a/tests/conftest.py b/tests/conftest.py index fb049a8c..c86748e8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,23 +35,23 @@ BASE_HELP_VERBOSE = """ 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, save, or clear previously entered commands. -load Runs commands in script file that is encoded as either ASCII or UTF-8 text. +edit Edit a file in a text editor +help List available commands with "help" or detailed help with "help cmd" +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 -quit Exits this application. +quit Exits this application 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. +shell Execute a command as if at the OS prompt +shortcuts Lists shortcuts available unalias Unsets aliases """ # Help text for the history command HELP_HISTORY = """Usage: history [arg] [-h] [-r | -e | -s | -o FILE | -t TRANSCRIPT | -c] -View, run, edit, save, or clear previously entered commands. +View, run, edit, save, or clear previously entered commands positional arguments: arg empty all history items diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 6fb64b86..e2a3d854 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1235,15 +1235,15 @@ diddly This command does diddly Other ================================================================================ alias Define or display aliases -help List available commands with "help" or detailed help with "help cmd". -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. +help List available commands with "help" or detailed help with "help cmd" +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 -quit Exits this application. +quit Exits this application 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. +shell Execute a command as if at the OS prompt +shortcuts Lists shortcuts available unalias Unsets aliases Undocumented commands: @@ -1750,6 +1750,15 @@ def test_alias(base_app, capsys): out = run_cmd(base_app, 'alias fake') assert out == normalize('alias fake pyscript') +def test_alias_with_quotes(base_app, capsys): + # Create the alias + out = run_cmd(base_app, 'alias fake help ">" "out file.txt"') + assert out == normalize("Alias 'fake' created") + + # Lookup the new alias (Only the redirector should be unquoted) + out = run_cmd(base_app, 'alias fake') + assert out == normalize('alias fake help > "out file.txt"') + def test_alias_lookup_invalid_alias(base_app, capsys): # Lookup invalid alias out = run_cmd(base_app, 'alias invalid') diff --git a/tests/test_parsing.py b/tests/test_parsing.py index de4c637e..9cf9429a 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -5,6 +5,7 @@ Test the parsing logic in parsing.py Copyright 2017 Todd Leonhardt <todd.leonhardt@gmail.com> Released under MIT license, see LICENSE file """ +import attr import pytest import cmd2 @@ -31,12 +32,40 @@ def default_parser(): parser = StatementParser() return parser + +def test_parse_empty_string(parser): + line = '' + statement = parser.parse(line) + assert statement == '' + assert statement.args == statement + assert statement.raw == line + assert statement.command == '' + assert statement.arg_list == [] + assert statement.multiline_command == '' + assert statement.terminator == '' + assert statement.suffix == '' + assert statement.pipe_to == [] + assert statement.output == '' + assert statement.output_to == '' + assert statement.command_and_args == line + assert statement.argv == statement.arg_list + def test_parse_empty_string_default(default_parser): - statement = default_parser.parse('') - assert not statement.command - assert not statement.args + line = '' + statement = default_parser.parse(line) assert statement == '' - assert statement.raw == '' + assert statement.args == statement + assert statement.raw == line + assert statement.command == '' + assert statement.arg_list == [] + assert statement.multiline_command == '' + assert statement.terminator == '' + assert statement.suffix == '' + assert statement.pipe_to == [] + assert statement.output == '' + assert statement.output_to == '' + assert statement.command_and_args == line + assert statement.argv == statement.arg_list @pytest.mark.parametrize('line,tokens', [ ('command', ['command']), @@ -52,13 +81,6 @@ def test_tokenize_default(default_parser, line, tokens): tokens_to_test = default_parser.tokenize(line) assert tokens_to_test == tokens -def test_parse_empty_string(parser): - statement = parser.parse('') - assert not statement.command - assert not statement.args - assert statement == '' - assert statement.raw == '' - @pytest.mark.parametrize('line,tokens', [ ('command', ['command']), ('command /* with some comment */ arg', ['command', 'arg']), @@ -81,8 +103,8 @@ def test_tokenize_unclosed_quotes(parser): _ = parser.tokenize('command with "unclosed quotes') @pytest.mark.parametrize('tokens,command,args', [ - ([], None, None), - (['command'], 'command', None), + ([], '', ''), + (['command'], 'command', ''), (['command', 'arg1', 'arg2'], 'command', 'arg1 arg2') ]) def test_command_and_args(parser, tokens, command, args): @@ -98,9 +120,18 @@ 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 + assert statement.args == statement + assert statement.raw == line + assert statement.multiline_command == '' + assert statement.terminator == '' + assert statement.suffix == '' + assert statement.pipe_to == [] + assert statement.output == '' + assert statement.output_to == '' + assert statement.command_and_args == line @pytest.mark.parametrize('line,terminator', [ ('termbare;', ';'), @@ -111,9 +142,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 +156,10 @@ 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.args == statement assert statement.argv == ['termbare'] + assert not statement.arg_list assert statement.terminator == terminator assert statement.suffix == 'suffx' @@ -135,71 +167,82 @@ 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.args == statement 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.args == statement 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.args == statement 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.args == 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 statement == '' + assert statement.args == statement + assert statement.argv == ['hi'] + assert not statement.arg_list assert not statement.pipe_to def test_parse_c_comment_empty(parser): statement = parser.parse('/* this is | all a comment */') - assert not statement.command - assert not statement.args + assert statement.command == '' + assert statement.args == statement assert not statement.pipe_to 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 statement == '/tmp/*.txt' + assert statement.args == statement assert not statement.pipe_to 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 statement == '/tmp/*.txt /tmp/*.cfg' + assert statement.args == statement assert not statement.pipe_to 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.args == statement assert statement.argv == ['what', 'if', 'quoted strings /* seem to ', 'start', 'comments?'] - assert statement == statement.args + assert statement.arg_list == ['if', '"quoted strings /* seem to "', 'start', 'comments?'] assert not statement.pipe_to @pytest.mark.parametrize('line',[ @@ -209,27 +252,30 @@ 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.args == 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.args == statement assert statement.argv == ['double-pipe', '||', 'is', 'not', 'a', 'pipe'] + assert statement.arg_list == statement.argv[1:] assert not statement.pipe_to 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.args == statement 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,8 +289,8 @@ 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.args == statement assert statement.output == output assert statement.output_to == 'out.txt' @@ -252,9 +298,10 @@ 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.args == statement assert statement.argv == ['output', 'into'] + assert statement.arg_list == statement.argv[1:] assert statement.output == '>' assert statement.output_to == 'afile.txt' @@ -262,9 +309,10 @@ 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.args == statement 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 +320,10 @@ 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.args == statement 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 +331,24 @@ 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.args == statement 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 == '' + assert statement.output_to == '' 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.args == statement 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 +358,10 @@ 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.args == statement assert statement.argv == ['has', '>', 'inside'] + assert statement.arg_list == statement.argv[1:] assert statement.terminator == ';' @pytest.mark.parametrize('line,terminator',[ @@ -325,9 +377,10 @@ 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.args == statement 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 +388,11 @@ 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.args == statement assert statement.argv == ['multiline', 'has', '>', 'inside', 'an', 'unfinished', 'command'] - assert not statement.terminator + assert statement.arg_list == statement.argv[1:] + assert statement.terminator == '' @pytest.mark.parametrize('line,terminator',[ ('multiline has > inside;', ';'), @@ -350,9 +404,10 @@ 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.args == statement 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 +417,10 @@ 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.args == statement 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 +428,10 @@ 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.args == statement 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 +439,10 @@ 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.args == statement assert statement.argv == ['multiline', 'command', 'ends'] + assert statement.arg_list == statement.argv[1:] assert statement.terminator == '\n' @pytest.mark.parametrize('line,terminator',[ @@ -398,9 +457,10 @@ 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.args == statement 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 +468,38 @@ 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.args == statement 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.args == statement 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.args == statement 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.args == statement assert statement.argv == ['dir', 'home'] + assert statement.arg_list == statement.argv[1:] assert statement.output == '>' assert statement.output_to == 'café' @@ -452,9 +516,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 +527,17 @@ 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 + assert statement.args == statement 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.args == statement + assert statement == 'has > inside an unfinished command' + assert statement.terminator == '' @pytest.mark.parametrize('line,output', [ ('helpalias > out.txt', '>'), @@ -487,8 +548,8 @@ 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.args == statement assert statement.output == output assert statement.output_to == 'out.txt' @@ -499,8 +560,8 @@ 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.args == statement assert statement.pipe_to == ['less'] @pytest.mark.parametrize('line', [ @@ -514,76 +575,118 @@ 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.args == statement assert statement.terminator == ';' def test_parse_command_only_command_and_args(parser): line = 'help history' statement = parser.parse_command_only(line) + assert statement == 'history' + assert statement.args == statement + assert statement.arg_list == [] assert statement.command == 'help' - assert statement.args == 'history' - assert statement == statement.args assert statement.command_and_args == line - -def test_parse_command_only_emptyline(parser): - line = '' - statement = parser.parse_command_only(line) - # statement is a subclass of str(), the value of the str - # 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 statement.multiline_command == '' + assert statement.raw == line + assert statement.terminator == '' + assert statement.suffix == '' + assert statement.pipe_to == [] + assert statement.output == '' + assert statement.output_to == '' def test_parse_command_only_strips_line(parser): line = ' help history ' statement = parser.parse_command_only(line) + assert statement == 'history' + assert statement.args == statement + assert statement.arg_list == [] assert statement.command == 'help' - assert statement.args == 'history' - assert statement == statement.args assert statement.command_and_args == line.strip() + assert statement.multiline_command == '' + assert statement.raw == line + assert statement.terminator == '' + assert statement.suffix == '' + assert statement.pipe_to == [] + assert statement.output == '' + assert statement.output_to == '' def test_parse_command_only_expands_alias(parser): - line = 'fake foobar.py' + line = 'fake foobar.py "somebody.py' statement = parser.parse_command_only(line) + assert statement == 'foobar.py "somebody.py' + assert statement.args == statement + assert statement.arg_list == [] assert statement.command == 'pyscript' - assert statement.args == 'foobar.py' - assert statement == statement.args + assert statement.command_and_args == 'pyscript foobar.py "somebody.py' + assert statement.multiline_command == '' + assert statement.raw == line + assert statement.terminator == '' + assert statement.suffix == '' + assert statement.pipe_to == [] + assert statement.output == '' + assert statement.output_to == '' def test_parse_command_only_expands_shortcuts(parser): line = '!cat foobar.txt' statement = parser.parse_command_only(line) + assert statement == 'cat foobar.txt' + assert statement.args == statement + assert statement.arg_list == [] assert statement.command == 'shell' - assert statement.args == 'cat foobar.txt' - assert statement == statement.args assert statement.command_and_args == 'shell cat foobar.txt' + assert statement.multiline_command == '' + assert statement.raw == line + assert statement.multiline_command == '' + assert statement.terminator == '' + assert statement.suffix == '' + assert statement.pipe_to == [] + assert statement.output == '' + assert statement.output_to == '' def test_parse_command_only_quoted_args(parser): line = 'l "/tmp/directory with spaces/doit.sh"' statement = parser.parse_command_only(line) + assert statement == 'ls -al "/tmp/directory with spaces/doit.sh"' + assert statement.args == statement + assert statement.arg_list == [] assert statement.command == 'shell' - assert statement.args == 'ls -al "/tmp/directory with spaces/doit.sh"' - assert statement == statement.args assert statement.command_and_args == line.replace('l', 'shell ls -al') - -@pytest.mark.parametrize('line', [ - 'helpalias > out.txt', - 'helpalias>out.txt', - 'helpalias >> out.txt', - 'helpalias>>out.txt', - 'help|less', - 'helpalias;', - 'help ;;', - 'help; ;;', + assert statement.multiline_command == '' + assert statement.raw == line + assert statement.multiline_command == '' + assert statement.terminator == '' + assert statement.suffix == '' + assert statement.pipe_to == [] + assert statement.output == '' + assert statement.output_to == '' + +@pytest.mark.parametrize('line,args', [ + ('helpalias > out.txt', '> out.txt'), + ('helpalias>out.txt', '>out.txt'), + ('helpalias >> out.txt', '>> out.txt'), + ('helpalias>>out.txt', '>>out.txt'), + ('help|less', '|less'), + ('helpalias;', ';'), + ('help ;;', ';;'), + ('help; ;;', '; ;;'), ]) -def test_parse_command_only_specialchars(parser, line): +def test_parse_command_only_specialchars(parser, line, args): statement = parser.parse_command_only(line) + assert statement == args + assert statement.args == args assert statement.command == 'help' + assert statement.multiline_command == '' + assert statement.raw == line + assert statement.multiline_command == '' + assert statement.terminator == '' + assert statement.suffix == '' + assert statement.pipe_to == [] + assert statement.output == '' + assert statement.output_to == '' @pytest.mark.parametrize('line', [ + '', ';', ';;', ';; ;', @@ -595,34 +698,59 @@ def test_parse_command_only_specialchars(parser, line): '"', '|', ]) -def test_parse_command_only_none(parser, line): +def test_parse_command_only_empty(parser, line): statement = parser.parse_command_only(line) - assert statement.command is None - assert statement.args is None assert statement == '' + assert statement.args == statement + assert statement.arg_list == [] + assert statement.command == '' + assert statement.command_and_args == '' + assert statement.multiline_command == '' + assert statement.raw == line + assert statement.multiline_command == '' + assert statement.terminator == '' + assert statement.suffix == '' + assert statement.pipe_to == [] + assert statement.output == '' + assert statement.output_to == '' def test_parse_command_only_multiline(parser): line = 'multiline with partially "open quotes and no terminator' 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 + assert statement.args == statement -def test_statement_initialization(parser): +def test_statement_initialization(): string = 'alias' statement = cmd2.Statement(string) assert string == statement - assert statement.raw is None - assert statement.command is None - assert statement.args is None + assert statement.args == statement + assert statement.raw == '' + assert statement.command == '' + assert isinstance(statement.arg_list, list) + assert not statement.arg_list assert isinstance(statement.argv, list) assert not statement.argv - assert statement.multiline_command is None - assert statement.terminator is None - assert statement.suffix is None - assert statement.pipe_to is None - assert statement.output is None - assert statement.output_to is None + assert statement.multiline_command == '' + assert statement.terminator == '' + assert statement.suffix == '' + assert isinstance(statement.pipe_to, list) + assert not statement.pipe_to + assert statement.output == '' + assert statement.output_to == '' + + +def test_statement_is_immutable(): + string = 'foo' + statement = cmd2.Statement(string) + assert string == statement + assert statement.args == statement + assert statement.raw == '' + with pytest.raises(attr.exceptions.FrozenInstanceError): + statement.args = 'bar' + with pytest.raises(attr.exceptions.FrozenInstanceError): + statement.raw = 'baz' @@ -6,91 +6,16 @@ testpaths = tests [testenv] passenv = CI TRAVIS TRAVIS_* APPVEYOR* -setenv = - PYTHONPATH={toxinidir} +setenv = PYTHONPATH={toxinidir} +extras = test +commands = + py.test {posargs} --cov + codecov [testenv:docs] basepython = python3.5 deps = - sphinx<1.7.7 + sphinx sphinx-rtd-theme changedir = docs commands = sphinx-build -a -W -T -b html -d {envtmpdir}/doctrees . {envtmpdir}/html - -[testenv:py34] -deps = - codecov - pyperclip - pytest - pytest-cov - pytest-mock - argcomplete - wcwidth -commands = - py.test {posargs} --cov - codecov - -[testenv:py35] -deps = - mock - pyperclip - pytest - pytest-mock - argcomplete - wcwidth -commands = py.test -v - -[testenv:py35-win] -deps = - mock - pyperclip - pyreadline - pytest - pytest-mock -commands = py.test -v - -[testenv:py36] -deps = - codecov - pyperclip - pytest - pytest-cov - pytest-mock - argcomplete - wcwidth -commands = - py.test {posargs} --cov - codecov - -[testenv:py36-win] -deps = - codecov - pyperclip - pyreadline - pytest - pytest-cov - pytest-mock -commands = - py.test {posargs} --cov - codecov - -[testenv:py37] -deps = - pyperclip - pytest - pytest-mock - argcomplete - wcwidth -commands = py.test -v - -[testenv:py37-win] -deps = - codecov - pyperclip - pyreadline - pytest - pytest-cov - pytest-mock -commands = - py.test {posargs} --cov - codecov |