diff options
author | Todd Leonhardt <todd.leonhardt@gmail.com> | 2018-10-09 20:04:50 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-10-09 20:04:50 -0400 |
commit | f38e100fd77f4a136a4883d23b2f4f8b3cd934b7 (patch) | |
tree | c289c216807646567953191d35ebdc5c07198c24 | |
parent | 467be57e647112f536becc8625ffa080cb67a0ce (diff) | |
parent | 84f290bfdd82eb1c2eaf26b5936f7088b4911f2c (diff) | |
download | cmd2-git-f38e100fd77f4a136a4883d23b2f4f8b3cd934b7.tar.gz |
Merge pull request #571 from python-cmd2/argparse_remainder
Fixes related to handling of argparse.REMAINDER
-rw-r--r-- | CHANGELOG.md | 11 | ||||
-rwxr-xr-x | cmd2/argparse_completer.py | 138 | ||||
-rw-r--r-- | cmd2/cmd2.py | 4 | ||||
-rw-r--r-- | cmd2/pyscript_bridge.py | 84 | ||||
-rw-r--r-- | cmd2/utils.py | 10 | ||||
-rw-r--r-- | docs/argument_processing.rst | 28 | ||||
-rwxr-xr-x | examples/decorator_example.py (renamed from examples/argparse_example.py) | 9 | ||||
-rwxr-xr-x | examples/hello_cmd2.py (renamed from main.py) | 6 | ||||
-rwxr-xr-x | examples/tab_autocompletion.py | 4 | ||||
-rwxr-xr-x | examples/table_display.py | 26 | ||||
-rw-r--r-- | examples/transcripts/exampleSession.txt | 2 | ||||
-rw-r--r-- | tests/test_acargparse.py | 17 | ||||
-rw-r--r-- | tests/test_autocompletion.py | 2 | ||||
-rw-r--r-- | tests/test_completion.py | 24 | ||||
-rw-r--r-- | tests/test_pyscript.py | 19 | ||||
-rw-r--r-- | tests/test_utils.py | 12 |
16 files changed, 311 insertions, 85 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d800fcc..0042b86b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ * Fixed bug where **alias** command was dropping quotes around arguments * Fixed bug where running help on argparse commands didn't work if they didn't support -h * Fixed transcript testing bug where last command in transcript has no expected output + * Fixed bugs with how AutoCompleter and ArgparseFunctor handle argparse + arguments with nargs=argparse.REMAINDER. Tab completion now correctly + matches how argparse will parse the values. Command strings generated by + ArgparseFunctor should now be compliant with how argparse expects + REMAINDER arguments to be ordered. + * Fixed bugs with how AutoCompleter handles flag prefixes. It is no + longer hard-coded to use '-' and will check against the prefix_chars in + the argparse object. Also, single-character tokens that happen to be a + prefix char are not treated as flags by argparse and AutoCompleter now + matches that behavior. * Enhancements * Added ``exit_code`` attribute of ``cmd2.Cmd`` class * Enables applications to return a non-zero exit code when exiting from ``cmdloop`` @@ -24,6 +34,7 @@ * Never - output methods strip all ANSI escape sequences * Added ``macro`` command to create macros, which are similar to aliases, but can take arguments when called * All cmd2 command functions have been converted to use argparse. + * Renamed argparse_example.py to decorator_example.py to help clarify its intent * Deprecations * Deprecated the built-in ``cmd2`` support for colors including ``Cmd.colorize()`` and ``Cmd._colorcodes`` * Deletions (potentially breaking changes) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index c900a780..168a555f 100755 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -209,6 +209,34 @@ def register_custom_actions(parser: argparse.ArgumentParser) -> None: parser.register('action', 'append', _AppendRangeAction) +def token_resembles_flag(token: str, parser: argparse.ArgumentParser) -> bool: + """Determine if a token looks like a flag. Based on argparse._parse_optional().""" + # if it's an empty string, it was meant to be a positional + if not token: + return False + + # if it doesn't start with a prefix, it was meant to be positional + if not token[0] in parser.prefix_chars: + return False + + # if it's just a single character, it was meant to be positional + if len(token) == 1: + return False + + # if it looks like a negative number, it was meant to be positional + # unless there are negative-number-like options + if parser._negative_number_matcher.match(token): + if not parser._has_negative_number_optionals: + return False + + # if it contains a space, it was meant to be a positional + if ' ' in token: + return False + + # Looks like a flag + return True + + class AutoCompleter(object): """Automatically command line tab completion based on argparse parameters""" @@ -318,6 +346,9 @@ class AutoCompleter(object): flag_arg = AutoCompleter._ArgumentState() flag_action = None + # dict is used because object wrapper is necessary to allow inner functions to modify outer variables + remainder = {'arg': None, 'action': None} + matched_flags = [] current_is_positional = False consumed_arg_values = {} # dict(arg_name -> [values, ...]) @@ -331,8 +362,8 @@ class AutoCompleter(object): def consume_flag_argument() -> None: """Consuming token as a flag argument""" # we're consuming flag arguments - # if this is not empty and is not another potential flag, count towards flag arguments - if token and token[0] not in self._parser.prefix_chars and flag_action is not None: + # if the token does not look like a new flag, then count towards flag arguments + if not token_resembles_flag(token, self._parser) and flag_action is not None: flag_arg.count += 1 # does this complete a option item for the flag @@ -355,17 +386,79 @@ class AutoCompleter(object): consumed_arg_values.setdefault(pos_action.dest, []) consumed_arg_values[pos_action.dest].append(token) + def process_action_nargs(action: argparse.Action, arg_state: AutoCompleter._ArgumentState) -> None: + """Process the current argparse Action and initialize the ArgumentState object used + to track what arguments we have processed for this action""" + if isinstance(action, _RangeAction): + arg_state.min = action.nargs_min + arg_state.max = action.nargs_max + arg_state.variable = True + if arg_state.min is None or arg_state.max is None: + if action.nargs is None: + arg_state.min = 1 + arg_state.max = 1 + elif action.nargs == '+': + arg_state.min = 1 + arg_state.max = float('inf') + arg_state.variable = True + elif action.nargs == '*' or action.nargs == argparse.REMAINDER: + arg_state.min = 0 + arg_state.max = float('inf') + arg_state.variable = True + if action.nargs == argparse.REMAINDER: + remainder['action'] = action + remainder['arg'] = arg_state + elif action.nargs == '?': + arg_state.min = 0 + arg_state.max = 1 + arg_state.variable = True + else: + arg_state.min = action.nargs + arg_state.max = action.nargs + + # This next block of processing tries to parse all parameters before the last parameter. + # We're trying to determine what specific argument the current cursor positition should be + # matched with. When we finish parsing all of the arguments, we can determine whether the + # last token is a positional or flag argument and which specific argument it is. + # + # We're also trying to save every flag that has been used as well as every value that + # has been used for a positional or flag parameter. By saving this information we can exclude + # it from the completion results we generate for the last token. For example, single-use flag + # arguments will be hidden from the list of available flags. Also, arguments with a + # defined list of possible values will exclude values that have already been used. + + # notes when the last token has been reached is_last_token = False + for idx, token in enumerate(tokens): is_last_token = idx >= len(tokens) - 1 # Only start at the start token index if idx >= self._token_start_index: + # If a remainder action is found, force all future tokens to go to that + if remainder['arg'] is not None: + if remainder['action'] == pos_action: + consume_positional_argument() + continue + elif remainder['action'] == flag_action: + consume_flag_argument() + continue current_is_positional = False # Are we consuming flag arguments? if not flag_arg.needed: - # we're not consuming flag arguments, is the current argument a potential flag? - if len(token) > 0 and token[0] in self._parser.prefix_chars and\ - (is_last_token or (not is_last_token and token != '-')): + # Special case when each of the following is true: + # - We're not in the middle of consuming flag arguments + # - The current positional argument count has hit the max count + # - The next positional argument is a REMAINDER argument + # Argparse will now treat all future tokens as arguments to the positional including tokens that + # look like flags so the completer should skip any flag related processing once this happens + skip_flag = False + if (pos_action is not None) and pos_arg.count >= pos_arg.max and \ + next_pos_arg_index < len(self._positional_actions) and \ + self._positional_actions[next_pos_arg_index].nargs == argparse.REMAINDER: + skip_flag = True + + # At this point we're no longer consuming flag arguments. Is the current argument a potential flag? + if token_resembles_flag(token, self._parser) and not skip_flag: # reset some tracking values flag_arg.reset() # don't reset positional tracking because flags can be interspersed anywhere between positionals @@ -381,7 +474,7 @@ class AutoCompleter(object): if flag_action is not None: # resolve argument counts - self._process_action_nargs(flag_action, flag_arg) + process_action_nargs(flag_action, flag_arg) if not is_last_token and not isinstance(flag_action, argparse._AppendAction): matched_flags.extend(flag_action.option_strings) @@ -418,7 +511,7 @@ class AutoCompleter(object): return sub_completers[token].complete_command(tokens, text, line, begidx, endidx) pos_action = action - self._process_action_nargs(pos_action, pos_arg) + process_action_nargs(pos_action, pos_arg) consume_positional_argument() elif not is_last_token and pos_arg.max is not None: @@ -435,10 +528,13 @@ class AutoCompleter(object): if not is_last_token and flag_arg.min is not None: flag_arg.needed = flag_arg.count < flag_arg.min + # Here we're done parsing all of the prior arguments. We know what the next argument is. + # if we don't have a flag to populate with arguments and the last token starts with # a flag prefix then we'll complete the list of flag options completion_results = [] - if not flag_arg.needed and len(tokens[-1]) > 0 and tokens[-1][0] in self._parser.prefix_chars: + if not flag_arg.needed and len(tokens[-1]) > 0 and tokens[-1][0] in self._parser.prefix_chars and \ + remainder['arg'] is None: return AutoCompleter.basic_complete(text, line, begidx, endidx, [flag for flag in self._flags if flag not in matched_flags]) # we're not at a positional argument, see if we're in a flag argument @@ -522,32 +618,6 @@ class AutoCompleter(object): return completers[token].format_help(tokens) return self._parser.format_help() - @staticmethod - def _process_action_nargs(action: argparse.Action, arg_state: _ArgumentState) -> None: - if isinstance(action, _RangeAction): - arg_state.min = action.nargs_min - arg_state.max = action.nargs_max - arg_state.variable = True - if arg_state.min is None or arg_state.max is None: - if action.nargs is None: - arg_state.min = 1 - arg_state.max = 1 - elif action.nargs == '+': - arg_state.min = 1 - arg_state.max = float('inf') - arg_state.variable = True - elif action.nargs == '*': - arg_state.min = 0 - arg_state.max = float('inf') - arg_state.variable = True - elif action.nargs == '?': - arg_state.min = 0 - arg_state.max = 1 - arg_state.variable = True - else: - arg_state.min = action.nargs - arg_state.max = action.nargs - def _complete_for_arg(self, action: argparse.Action, text: str, line: str, diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 4502c53a..c000fb80 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -193,7 +193,7 @@ def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser, preserve """A decorator to alter a cmd2 method to populate its ``args`` argument by parsing arguments with the given instance of argparse.ArgumentParser, but also returning unknown args as a list. - :param argparser: given instance of ArgumentParser + :param argparser: unique instance of ArgumentParser :param preserve_quotes: if True, then the arguments passed to arparse be maintain their quotes :return: function that gets passed parsed args and a list of unknown args """ @@ -234,7 +234,7 @@ def with_argparser(argparser: argparse.ArgumentParser, preserve_quotes: bool=Fal """A decorator to alter a cmd2 method to populate its ``args`` argument by parsing arguments with the given instance of argparse.ArgumentParser. - :param argparser: given instance of ArgumentParser + :param argparser: unique instance of ArgumentParser :param preserve_quotes: if True, then the arguments passed to arparse be maintain their quotes :return: function that gets passed parsed args """ diff --git a/cmd2/pyscript_bridge.py b/cmd2/pyscript_bridge.py index 7c09aab0..11a2cbb3 100644 --- a/cmd2/pyscript_bridge.py +++ b/cmd2/pyscript_bridge.py @@ -12,8 +12,8 @@ import functools import sys from typing import List, Callable, Optional -from .argparse_completer import _RangeAction -from .utils import namedtuple_with_defaults, StdSim +from .argparse_completer import _RangeAction, token_resembles_flag +from .utils import namedtuple_with_defaults, StdSim, quote_string_if_needed # Python 3.4 require contextlib2 for temporarily redirecting stderr and stdout if sys.version_info < (3, 5): @@ -82,6 +82,10 @@ class ArgparseFunctor: # Dictionary mapping command argument name to value self._args = {} + # tag the argument that's a remainder type + self._remainder_arg = None + # separately track flag arguments so they will be printed before positionals + self._flag_args = [] # argparse object for the current command layer self.__current_subcommand_parser = parser @@ -116,7 +120,6 @@ class ArgparseFunctor: next_pos_index = 0 has_subcommand = False - consumed_kw = [] # Iterate through the current sub-command's arguments in order for action in self.__current_subcommand_parser._actions: @@ -125,7 +128,7 @@ class ArgparseFunctor: # this is a flag argument, search for the argument by name in the parameters if action.dest in kwargs: self._args[action.dest] = kwargs[action.dest] - consumed_kw.append(action.dest) + self._flag_args.append(action.dest) else: # This is a positional argument, search the positional arguments passed in. if not isinstance(action, argparse._SubParsersAction): @@ -164,6 +167,10 @@ class ArgparseFunctor: elif action.nargs == '*': self._args[action.dest] = args[next_pos_index:next_pos_index + pos_remain] next_pos_index += pos_remain + elif action.nargs == argparse.REMAINDER: + self._args[action.dest] = args[next_pos_index:next_pos_index + pos_remain] + next_pos_index += pos_remain + self._remainder_arg = action.dest elif action.nargs == '?': self._args[action.dest] = args[next_pos_index] next_pos_index += 1 @@ -175,7 +182,7 @@ class ArgparseFunctor: # Check if there are any extra arguments we don't know how to handle for kw in kwargs: - if kw not in self._args: # consumed_kw: + if kw not in self._args: raise TypeError("{}() got an unexpected keyword argument '{}'".format( self.__current_subcommand_parser.prog, kw)) @@ -194,7 +201,7 @@ class ArgparseFunctor: # reconstruct the cmd2 command from the python call cmd_str = [''] - def process_flag(action, value): + def process_argument(action, value): if isinstance(action, argparse._CountAction): if isinstance(value, int): for _ in range(value): @@ -218,30 +225,61 @@ class ArgparseFunctor: if isinstance(value, List) or isinstance(value, tuple): for item in value: item = str(item).strip() - if ' ' in item: - item = '"{}"'.format(item) + if token_resembles_flag(item, self._parser): + raise ValueError('{} appears to be a flag and should be supplied as a keyword argument ' + 'to the function.'.format(item)) + item = quote_string_if_needed(item) cmd_str[0] += '{} '.format(item) + + # If this is a flag parameter that can accept a variable number of arguments and we have not + # reached the max number, add a list completion suffix to tell argparse to move to the next + # parameter + if action.option_strings and isinstance(action, _RangeAction) and action.nargs_max is not None and \ + action.nargs_max > len(value): + cmd_str[0] += '{0}{0} '.format(self._parser.prefix_chars[0]) + else: value = str(value).strip() - if ' ' in value: - value = '"{}"'.format(value) + if token_resembles_flag(value, self._parser): + raise ValueError('{} appears to be a flag and should be supplied as a keyword argument ' + 'to the function.'.format(value)) + value = quote_string_if_needed(value) cmd_str[0] += '{} '.format(value) + # If this is a flag parameter that can accept a variable number of arguments and we have not + # reached the max number, add a list completion suffix to tell argparse to move to the next + # parameter + if action.option_strings and isinstance(action, _RangeAction) and action.nargs_max is not None and \ + action.nargs_max > 1: + cmd_str[0] += '{0}{0} '.format(self._parser.prefix_chars[0]) + + def process_action(action): + if isinstance(action, argparse._SubParsersAction): + cmd_str[0] += '{} '.format(self._args[action.dest]) + traverse_parser(action.choices[self._args[action.dest]]) + elif isinstance(action, argparse._AppendAction): + if isinstance(self._args[action.dest], list) or isinstance(self._args[action.dest], tuple): + for values in self._args[action.dest]: + process_argument(action, values) + else: + process_argument(action, self._args[action.dest]) + else: + process_argument(action, self._args[action.dest]) + def traverse_parser(parser): + # first process optional flag arguments for action in parser._actions: - # was something provided for the argument - if action.dest in self._args: - if isinstance(action, argparse._SubParsersAction): - cmd_str[0] += '{} '.format(self._args[action.dest]) - traverse_parser(action.choices[self._args[action.dest]]) - elif isinstance(action, argparse._AppendAction): - if isinstance(self._args[action.dest], list) or isinstance(self._args[action.dest], tuple): - for values in self._args[action.dest]: - process_flag(action, values) - else: - process_flag(action, self._args[action.dest]) - else: - process_flag(action, self._args[action.dest]) + if action.dest in self._args and action.dest in self._flag_args and action.dest != self._remainder_arg: + process_action(action) + # next process positional arguments + for action in parser._actions: + if action.dest in self._args and action.dest not in self._flag_args and \ + action.dest != self._remainder_arg: + process_action(action) + # Keep remainder argument last + for action in parser._actions: + if action.dest in self._args and action.dest == self._remainder_arg: + process_action(action) traverse_parser(self._parser) diff --git a/cmd2/utils.py b/cmd2/utils.py index 501733a9..d4a3db2f 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -255,10 +255,13 @@ class StdSim(object): """ class ByteBuf(object): """Inner class which stores an actual bytes buffer and does the actual output if echo is enabled.""" - def __init__(self, inner_stream, echo: bool = False) -> None: + def __init__(self, inner_stream, echo: bool = False, + encoding: str='utf-8', errors: str='replace') -> None: self.byte_buf = b'' self.inner_stream = inner_stream self.echo = echo + self.encoding = encoding + self.errors = errors def write(self, b: bytes) -> None: """Add bytes to internal bytes buffer and if echo is True, echo contents to inner stream.""" @@ -266,7 +269,10 @@ class StdSim(object): raise TypeError('a bytes-like object is required, not {}'.format(type(b))) self.byte_buf += b if self.echo: - self.inner_stream.buffer.write(b) + if hasattr(self.inner_stream, 'buffer'): + self.inner_stream.buffer.write(b) + else: + self.inner_stream.write(b.decode(encoding=self.encoding, errors=self.errors)) def __init__(self, inner_stream, echo: bool = False, encoding: str='utf-8', errors: str='replace') -> None: diff --git a/docs/argument_processing.rst b/docs/argument_processing.rst index 8aed7498..8931c60b 100644 --- a/docs/argument_processing.rst +++ b/docs/argument_processing.rst @@ -15,20 +15,20 @@ Argument Processing These features are all provided by the ``@with_argparser`` decorator which is importable from ``cmd2``. -See the either the argprint_ or argparse_ example to learn more about how to use the various ``cmd2`` argument +See the either the argprint_ or decorator_ example to learn more about how to use the various ``cmd2`` argument processing decorators in your ``cmd2`` applications. .. _argprint: https://github.com/python-cmd2/cmd2/blob/master/examples/arg_print.py -.. _argparse: https://github.com/python-cmd2/cmd2/blob/master/examples/argparse_example.py +.. _decorator: https://github.com/python-cmd2/cmd2/blob/master/examples/decorator_example.py Using the argument parser decorator =================================== For each command in the ``cmd2`` subclass which requires argument parsing, -create an instance of ``argparse.ArgumentParser()`` which can parse the +create a unique instance of ``argparse.ArgumentParser()`` which can parse the input appropriately for the command. Then decorate the command method with the ``@with_argparser`` decorator, passing the argument parser as the -first parameter to the decorator. This changes the second argumen to the command method, which will contain the results +first parameter to the decorator. This changes the second argument to the command method, which will contain the results of ``ArgumentParser.parse_args()``. Here's what it looks like:: @@ -54,6 +54,16 @@ Here's what it looks like:: for i in range(min(repetitions, self.maxrepeats)): self.poutput(arg) +.. warning:: + + It is important that each command which uses the ``@with_argparser`` decorator be passed a unique instance of a + parser. This limitation is due to bugs in CPython prior to Python 3.7 which make it impossible to make a deep copy + of an instance of a ``argparse.ArgumentParser``. + + See the table_display_ example for a work-around that demonstrates how to create a function which returns a unique + instance of the parser you want. + + .. note:: The ``@with_argparser`` decorator sets the ``prog`` variable in @@ -61,6 +71,8 @@ Here's what it looks like:: This will override anything you specify in ``prog`` variable when creating the argument parser. +.. _table_display: https://github.com/python-cmd2/cmd2/blob/master/examples/table_display.py + Help Messages ============= @@ -159,6 +171,14 @@ Which yields: This command can not generate tags with no content, like <br/> +.. warning:: + + If a command **foo** is decorated with one of cmd2's argparse decorators, then **help_foo** will not + be invoked when ``help foo`` is called. The argparse_ module provides a rich API which can be used to + tweak every aspect of the displayed help and we encourage ``cmd2`` developers to utilize that. + +.. _argparse: https://docs.python.org/3/library/argparse.html + Grouping Commands ================= diff --git a/examples/argparse_example.py b/examples/decorator_example.py index 236e2af4..5b8b303b 100755 --- a/examples/argparse_example.py +++ b/examples/decorator_example.py @@ -1,14 +1,13 @@ #!/usr/bin/env python # coding=utf-8 -"""A sample application for cmd2 showing how to use argparse to +"""A sample application showing how to use cmd2's argparse decorators to process command line arguments for your application. Thanks to cmd2's built-in transcript testing capability, it also -serves as a test suite for argparse_example.py when used with the -exampleSession.txt transcript. +serves as a test suite when used with the exampleSession.txt transcript. -Running `python argparse_example.py -t exampleSession.txt` will run -all the commands in the transcript against argparse_example.py, +Running `python decorator_example.py -t exampleSession.txt` will run +all the commands in the transcript against decorator_example.py, verifying that the output produced matches the transcript. """ import argparse diff --git a/main.py b/examples/hello_cmd2.py index 56383f66..397856a6 100755 --- a/main.py +++ b/examples/hello_cmd2.py @@ -1,5 +1,8 @@ #!/usr/bin/env python # coding=utf-8 +""" +This is intended to be a completely bare-bones cmd2 application suitable for rapid testing and debugging. +""" from cmd2 import cmd2 if __name__ == '__main__': @@ -8,5 +11,6 @@ if __name__ == '__main__': # Set "use_ipython" to True to include the ipy command if IPython is installed, which supports advanced interactive # debugging of your application via introspection on self. app = cmd2.Cmd(use_ipython=True, persistent_history_file='cmd2_history.txt') - app.locals_in_py = True + app.locals_in_py = True # Enable access to "self" within the py command + app.debug = True # Show traceback if/when an exception occurs app.cmdloop() diff --git a/examples/tab_autocompletion.py b/examples/tab_autocompletion.py index ef283e9e..571b4082 100755 --- a/examples/tab_autocompletion.py +++ b/examples/tab_autocompletion.py @@ -298,6 +298,8 @@ class TabCompleteExample(cmd2.Cmd): .format(movie['title'], movie['rating'], movie_id, ', '.join(movie['director']), '\n '.join(movie['actor']))) + elif args.command == 'add': + print('Adding Movie\n----------------\nTitle: {}\nRating: {}\nDirectors: {}\nActors: {}\n\n'.format(args.title, args.rating, ', '.join(args.director), ', '.join(args.actor))) def _do_media_shows(self, args) -> None: if not args.command: @@ -336,7 +338,7 @@ class TabCompleteExample(cmd2.Cmd): movies_add_parser.add_argument('title', help='Movie Title') movies_add_parser.add_argument('rating', help='Movie Rating', choices=ratings_types) movies_add_parser.add_argument('-d', '--director', help='Director', nargs=(1, 2), required=True) - movies_add_parser.add_argument('actor', help='Actors', nargs='*') + movies_add_parser.add_argument('actor', help='Actors', nargs=argparse.REMAINDER) movies_delete_parser = movies_commands_subparsers.add_parser('delete') movies_delete_movie_id = movies_delete_parser.add_argument('movie_id', help='Movie ID') diff --git a/examples/table_display.py b/examples/table_display.py index 63447377..7541e548 100755 --- a/examples/table_display.py +++ b/examples/table_display.py @@ -11,7 +11,6 @@ You can quit out of the pager by typing "q". You can also search for text withi WARNING: This example requires the tableformatter module: https://github.com/python-tableformatter/tableformatter - pip install tableformatter """ -import argparse from typing import Tuple import cmd2 @@ -142,6 +141,21 @@ def high_density_objs(row_obj: CityInfo) -> dict: return opts +def make_table_parser() -> cmd2.argparse_completer.ACArgumentParser: + """Create a unique instance of an argparse Argument parser for processing table arguments. + + NOTE: The two cmd2 argparse decorators require that each parser be unique, even if they are essentially a deep copy + of each other. For cases like that, you can create a function to return a unique instance of a parser, which is + what is being done here. + """ + table_parser = cmd2.argparse_completer.ACArgumentParser() + table_item_group = table_parser.add_mutually_exclusive_group() + table_item_group.add_argument('-c', '--color', action='store_true', help='Enable color') + table_item_group.add_argument('-f', '--fancy', action='store_true', help='Fancy Grid') + table_item_group.add_argument('-s', '--sparse', action='store_true', help='Sparse Grid') + return table_parser + + class TableDisplay(cmd2.Cmd): """Example cmd2 application showing how you can display tabular data.""" @@ -169,18 +183,12 @@ class TableDisplay(cmd2.Cmd): formatted_table = tf.generate_table(rows=rows, columns=columns, grid_style=grid, row_tagger=row_stylist) self.ppaged(formatted_table, chop=True) - table_parser = argparse.ArgumentParser() - table_item_group = table_parser.add_mutually_exclusive_group() - table_item_group.add_argument('-c', '--color', action='store_true', help='Enable color') - table_item_group.add_argument('-f', '--fancy', action='store_true', help='Fancy Grid') - table_item_group.add_argument('-s', '--sparse', action='store_true', help='Sparse Grid') - - @cmd2.with_argparser(table_parser) + @cmd2.with_argparser(make_table_parser()) def do_table(self, args): """Display data in iterable form on the Earth's most populated cities in a table.""" self.ptable(EXAMPLE_ITERABLE_DATA, COLUMNS, args, high_density_tuples) - @cmd2.with_argparser(table_parser) + @cmd2.with_argparser(make_table_parser()) def do_object_table(self, args): """Display data in object form on the Earth's most populated cities in a table.""" self.ptable(EXAMPLE_OBJECT_DATA, OBJ_COLS, args, high_density_objs) diff --git a/examples/transcripts/exampleSession.txt b/examples/transcripts/exampleSession.txt index 38fb0659..8fa7c9bb 100644 --- a/examples/transcripts/exampleSession.txt +++ b/examples/transcripts/exampleSession.txt @@ -1,4 +1,4 @@ -# Run this transcript with "python argparse_example.py -t exampleSession.txt" +# Run this transcript with "python decorator_example.py -t exampleSession.txt" # The regex for colors is because no color on Windows. # The regex for editor will match whatever program you use. # regexes on prompts just make the trailing space obvious diff --git a/tests/test_acargparse.py b/tests/test_acargparse.py index be3e8b97..617afd4f 100644 --- a/tests/test_acargparse.py +++ b/tests/test_acargparse.py @@ -5,7 +5,7 @@ Copyright 2018 Eric Lin <anselor@gmail.com> Released under MIT license, see LICENSE file """ import pytest -from cmd2.argparse_completer import ACArgumentParser +from cmd2.argparse_completer import ACArgumentParser, token_resembles_flag def test_acarg_narg_empty_tuple(): @@ -51,3 +51,18 @@ def test_acarg_narg_tuple_zero_base(): def test_acarg_narg_tuple_zero_to_one(): parser = ACArgumentParser(prog='test') parser.add_argument('tuple', nargs=(0, 1)) + + +def test_token_resembles_flag(): + parser = ACArgumentParser() + + # Not valid flags + assert not token_resembles_flag('', parser) + assert not token_resembles_flag('non-flag', parser) + assert not token_resembles_flag('-', parser) + assert not token_resembles_flag('--has space', parser) + assert not token_resembles_flag('-2', parser) + + # Valid flags + assert token_resembles_flag('-flag', parser) + assert token_resembles_flag('--flag', parser) diff --git a/tests/test_autocompletion.py b/tests/test_autocompletion.py index c6c1d1f6..3473ab38 100644 --- a/tests/test_autocompletion.py +++ b/tests/test_autocompletion.py @@ -36,7 +36,7 @@ optional arguments: MEDIA_MOVIES_ADD_HELP = '''Usage: media movies add -d DIRECTOR{1..2} [-h] - title {G, PG, PG-13, R, NC-17} [actor [...]] + title {G, PG, PG-13, R, NC-17} ... positional arguments: title Movie Title diff --git a/tests/test_completion.py b/tests/test_completion.py index d722e534..ed36eb01 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -716,6 +716,30 @@ def test_add_opening_quote_delimited_space_in_prefix(cmd2_app): os.path.commonprefix(cmd2_app.completion_matches) == expected_common_prefix and \ cmd2_app.display_matches == expected_display +def test_argparse_remainder_completion(cmd2_app): + # First test a positional with nargs=argparse.REMAINDER + text = '--h' + line = 'help command subcommand {}'.format(text) + endidx = len(line) + begidx = endidx - len(text) + + # --h should not complete into --help because we are in the argparse.REMAINDER sections + assert complete_tester(text, line, begidx, endidx, cmd2_app) is None + + # Now test a flag with nargs=argparse.REMAINDER + parser = argparse.ArgumentParser() + parser.add_argument('-f', nargs=argparse.REMAINDER) + + # Overwrite eof's parser for this test + cmd2.Cmd.do_eof.argparser = parser + + text = '--h' + line = 'eof -f {}'.format(text) + endidx = len(line) + begidx = endidx - len(text) + + # --h should not complete into --help because we are in the argparse.REMAINDER sections + assert complete_tester(text, line, begidx, endidx, cmd2_app) is None @pytest.fixture def sc_app(): diff --git a/tests/test_pyscript.py b/tests/test_pyscript.py index 6b72e940..bcb72a3b 100644 --- a/tests/test_pyscript.py +++ b/tests/test_pyscript.py @@ -9,7 +9,7 @@ import os import pytest from cmd2.cmd2 import Cmd, with_argparser from cmd2 import argparse_completer -from .conftest import run_cmd +from .conftest import run_cmd, normalize from cmd2.utils import namedtuple_with_defaults, StdSim @@ -234,3 +234,20 @@ def test_pyscript_custom_name(ps_echo, request): out = run_cmd(ps_echo, 'pyscript {}'.format(python_script)) assert out assert message == out[0] + + +def test_pyscript_argparse_checks(ps_app, capsys): + # Test command that has nargs.REMAINDER and make sure all tokens are accepted + run_cmd(ps_app, 'py app.alias.create("my_alias", "alias_command", "command_arg1", "command_arg2")') + out = run_cmd(ps_app, 'alias list my_alias') + assert out == normalize('alias create my_alias alias_command command_arg1 command_arg2') + + # Specify flag outside of keyword argument + run_cmd(ps_app, 'py app.help("-h")') + _, err = capsys.readouterr() + assert '-h appears to be a flag' in err + + # Specify list with flag outside of keyword argument + run_cmd(ps_app, 'py app.help(["--help"])') + _, err = capsys.readouterr() + assert '--help appears to be a flag' in err diff --git a/tests/test_utils.py b/tests/test_utils.py index 43a05a9a..807bc0fd 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -119,11 +119,23 @@ def stdout_sim(): stdsim = cu.StdSim(sys.stdout) return stdsim +@pytest.fixture +def stringio_sim(): + import io + stdsim = cu.StdSim(io.StringIO(), echo=True) + return stdsim + + def test_stdsim_write_str(stdout_sim): my_str = 'Hello World' stdout_sim.write(my_str) assert stdout_sim.getvalue() == my_str +def test_stdsim_write_str_inner_no_buffer(stringio_sim): + my_str = 'Hello World' + stringio_sim.write(my_str) + assert stringio_sim.getvalue() == my_str + def test_stdsim_write_bytes(stdout_sim): b_str = b'Hello World' with pytest.raises(TypeError): |