diff options
-rw-r--r-- | .github/workflows/codeql-analysis.yml | 66 | ||||
-rw-r--r-- | CHANGELOG.md | 13 | ||||
-rwxr-xr-x | README.md | 5 | ||||
-rw-r--r-- | cmd2/argparse_custom.py | 24 | ||||
-rw-r--r-- | cmd2/cmd2.py | 105 | ||||
-rwxr-xr-x | cmd2/parsing.py | 5 | ||||
-rw-r--r-- | cmd2/utils.py | 30 | ||||
-rw-r--r-- | docs/api/utils.rst | 4 | ||||
-rw-r--r-- | docs/features/argument_processing.rst | 19 | ||||
-rw-r--r-- | docs/features/modular_commands.rst | 2 | ||||
-rw-r--r-- | docs/features/settings.rst | 4 | ||||
-rwxr-xr-x | setup.py | 3 | ||||
-rwxr-xr-x | tests/test_cmd2.py | 38 |
13 files changed, 240 insertions, 78 deletions
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 00000000..0b2a764c --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,66 @@ +name: "CodeQL" + +on: + push: + branches: [master] + pull_request: + # The branches below must be a subset of the branches above + branches: [master] + schedule: + - cron: '0 6 * * 4' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + # Override automatic language detection by changing the below list + # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] + language: ['python'] + # Learn more... + # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + with: + # We must fetch at least the immediate parents so that if this is + # a pull request then we can checkout the head. + fetch-depth: 2 + + # If this run was triggered by a pull request event, then checkout + # the head of the pull request instead of the merge commit. + - run: git checkout HEAD^2 + if: ${{ github.event_name == 'pull_request' }} + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v1 + + # âšī¸ Command-line programs to run using the OS shell. + # đ https://git.io/JvXDl + + # âī¸ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index fab390d9..460a2018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,19 @@ See [read_input.py](https://github.com/python-cmd2/cmd2/blob/master/examples/read_input.py) for an example. +## 1.4.0 (November 11, 2020) +* Bug Fixes + * Fixed tab completion crash on Windows +* Enhancements + * Changed how multiline doc string help is formatted to match style of other help messages + +## 1.3.11 (October 1, 2020) +* Bug Fixes + * Fixed issue where quoted redirectors and terminators in aliases and macros were not being + restored when read from a startup script. + * Fixed issue where instantiating more than one cmd2-based class which uses the `@as_subcommand_to` + decorator resulted in duplicated help text in the base command the subcommands belong to. + ## 1.3.10 (September 17, 2020) * Enhancements * Added user-settable option called `always_show_hint`. If True, then tab completion hints will always @@ -123,9 +123,8 @@ Instructions for implementing each feature follow. example in conjunction with the [conditional.py](https://github.com/python-cmd2/cmd2/blob/master/examples/scripts/conditional.py) script - Parsing commands with `argparse` - - Two decorators provide built-in capability for using `argparse.ArgumentParser` to parse command arguments - - `cmd2.with_argparser` - all arguments are parsed by the `ArgumentParser` - - `cmd2.with_argparser_and_unknown_args` - any arguments not parsed by the `ArgumentParser` get passed as a list + - The built-in `cmd2.with_argparser` decorator will parse arguments using `argparse.ArgumentParser` + - Optionally, `cmd2.with_argparser(.., with_unknown_args=True)` can be used to pass all unknown arguments as a list ```Python import argparse diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 4e0a9708..3cf4d1ab 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -503,6 +503,7 @@ argparse.ArgumentParser._match_argument = _match_argument_wrapper # Patch argparse._SubParsersAction to add remove_parser function ############################################################################################################ +# noinspection PyPep8Naming def _SubParsersAction_remove_parser(self, name: str): """ Removes a sub-parser from a sub-parsers group @@ -511,23 +512,23 @@ def _SubParsersAction_remove_parser(self, name: str): class so cmd2 can remove subcommands from a parser. :param self: instance of the _SubParsersAction being edited - :param name: name of the sub-parser to remove + :param name: name of the subcommand for the sub-parser to remove """ + # Remove this subcommand from its base command's help text for choice_action in self._choices_actions: if choice_action.dest == name: self._choices_actions.remove(choice_action) break - subparser = self._name_parser_map[name] - to_remove = [] - for name, parser in self._name_parser_map.items(): - if parser is subparser: - to_remove.append(name) - for name in to_remove: - del self._name_parser_map[name] - - if name in self.choices: - del self.choices[name] + # Remove this subcommand and all its aliases from the base command + subparser = self._name_parser_map.get(name) + if subparser is not None: + to_remove = [] + for cur_name, cur_parser in self._name_parser_map.items(): + if cur_parser is subparser: + to_remove.append(cur_name) + for cur_name in to_remove: + del self._name_parser_map[cur_name] # noinspection PyProtectedMember @@ -686,6 +687,7 @@ class Cmd2HelpFormatter(argparse.RawTextHelpFormatter): return ', '.join(action.option_strings) + ' ' + args_string # End cmd2 customization + # noinspection PyMethodMayBeStatic def _determine_metavar(self, action, default_metavar) -> Union[str, Tuple]: """Custom method to determine what to use as the metavar value of an action""" if action.metavar is not None: diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index af046612..9fe31fd7 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -35,6 +35,7 @@ import glob import inspect import os import pickle +import pydoc import re import sys import threading @@ -678,10 +679,6 @@ class Cmd(cmd.Cmd): raise CommandSetRegistrationError('Could not find argparser for command "{}" needed by subcommand: {}' .format(command_name, str(method))) - # Set the subcommand handler function - defaults = {constants.NS_ATTR_SUBCMD_HANDLER: method} - subcmd_parser.set_defaults(**defaults) - def find_subcommand(action: argparse.ArgumentParser, subcmd_names: List[str]) -> argparse.ArgumentParser: if not subcmd_names: return action @@ -698,6 +695,14 @@ class Cmd(cmd.Cmd): for action in target_parser._actions: if isinstance(action, argparse._SubParsersAction): + # Temporary workaround for avoiding subcommand help text repeatedly getting added to + # action._choices_actions. Until we have instance-specific parser objects, we will remove + # any existing subcommand which has the same name before replacing it. This problem is + # exercised when more than one cmd2.Cmd-based object is created and the same subcommands + # get added each time. Argparse overwrites the previous subcommand but keeps growing the help + # text which is shown by running something like 'alias -h'. + action.remove_parser(subcommand_name) + # Get the kwargs for add_parser() add_parser_kwargs = getattr(method, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS, {}) @@ -722,6 +727,12 @@ class Cmd(cmd.Cmd): add_parser_kwargs['add_help'] = False attached_parser = action.add_parser(subcommand_name, **add_parser_kwargs) + + # Set the subcommand handler + defaults = {constants.NS_ATTR_SUBCMD_HANDLER: method} + attached_parser.set_defaults(**defaults) + + # Set what instance the handler is bound to setattr(attached_parser, constants.PARSER_ATTR_COMMANDSET, cmdset) break @@ -1597,7 +1608,7 @@ class Cmd(cmd.Cmd): matches_to_display, _ = self._pad_matches_to_display(matches_to_display) # Print any metadata like a hint or table header - readline.rl.mode.console.write(sys.stdout.write(self._build_completion_metadata_string())) + readline.rl.mode.console.write(self._build_completion_metadata_string()) # Display matches using actual display function. This also redraws the prompt and line. orig_pyreadline_display(matches_to_display) @@ -2914,20 +2925,39 @@ class Cmd(cmd.Cmd): @as_subcommand_to('alias', 'list', alias_list_parser, help=alias_delete_help) def _alias_list(self, args: argparse.Namespace) -> None: - """List some or all aliases""" + """List some or all aliases as 'alias create' commands""" create_cmd = "alias create" if args.with_silent: create_cmd += " --silent" + tokens_to_quote = constants.REDIRECTION_TOKENS + tokens_to_quote.extend(self.statement_parser.terminators) + if args.names: - for cur_name in utils.remove_duplicates(args.names): - if cur_name in self.aliases: - self.poutput("{} {} {}".format(create_cmd, cur_name, self.aliases[cur_name])) - else: - self.perror("Alias '{}' not found".format(cur_name)) + to_list = utils.remove_duplicates(args.names) else: - for cur_alias in sorted(self.aliases, key=self.default_sort_key): - self.poutput("{} {} {}".format(create_cmd, cur_alias, self.aliases[cur_alias])) + to_list = sorted(self.aliases, key=self.default_sort_key) + + not_found = [] # type: List[str] + for name in to_list: + if name not in self.aliases: + not_found.append(name) + continue + + # Quote redirection and terminator tokens for the 'alias create' command + tokens = shlex_split(self.aliases[name]) + command = tokens[0] + args = tokens[1:] + utils.quote_specific_tokens(args, tokens_to_quote) + + val = command + if args: + val += ' ' + ' '.join(args) + + self.poutput("{} {} {}".format(create_cmd, name, val)) + + for name in not_found: + self.perror("Alias '{}' not found".format(name)) ############################################################# # Parsers and functions for macro command and subcommands @@ -3122,20 +3152,39 @@ class Cmd(cmd.Cmd): @as_subcommand_to('macro', 'list', macro_list_parser, help=macro_list_help) def _macro_list(self, args: argparse.Namespace) -> None: - """List some or all macros""" + """List some or all macros as 'macro create' commands""" create_cmd = "macro create" if args.with_silent: create_cmd += " --silent" + tokens_to_quote = constants.REDIRECTION_TOKENS + tokens_to_quote.extend(self.statement_parser.terminators) + if args.names: - for cur_name in utils.remove_duplicates(args.names): - if cur_name in self.macros: - self.poutput("{} {} {}".format(create_cmd, cur_name, self.macros[cur_name].value)) - else: - self.perror("Macro '{}' not found".format(cur_name)) + to_list = utils.remove_duplicates(args.names) else: - for cur_macro in sorted(self.macros, key=self.default_sort_key): - self.poutput("{} {} {}".format(create_cmd, cur_macro, self.macros[cur_macro].value)) + to_list = sorted(self.macros, key=self.default_sort_key) + + not_found = [] # type: List[str] + for name in to_list: + if name not in self.macros: + not_found.append(name) + continue + + # Quote redirection and terminator tokens for the 'macro create' command + tokens = shlex_split(self.macros[name].value) + command = tokens[0] + args = tokens[1:] + utils.quote_specific_tokens(args, tokens_to_quote) + + val = command + if args: + val += ' ' + ' '.join(args) + + self.poutput("{} {} {}".format(create_cmd, name, val)) + + for name in not_found: + self.perror("Macro '{}' not found".format(name)) def complete_help_command(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: """Completes the command argument of help""" @@ -3198,17 +3247,21 @@ class Cmd(cmd.Cmd): # Set end to blank so the help output matches how it looks when "command -h" is used self.poutput(completer.format_help(args.subcommands), end='') + # If there is a help func delegate to do_help + elif help_func is not None: + super().do_help(args.command) + + # If there's no help_func __doc__ then format and output it + elif func is not None and func.__doc__ is not None: + self.poutput(pydoc.getdoc(func)) + # If there is no help information then print an error - elif help_func is None and (func is None or not func.__doc__): + else: err_msg = self.help_error.format(args.command) # Set apply_style to False so help_error's style is not overridden self.perror(err_msg, apply_style=False) - # Otherwise delegate to cmd base class do_help() - else: - super().do_help(args.command) - def _help_menu(self, verbose: bool = False) -> None: """Show a list of commands which help can be displayed for""" cmds_cats, cmds_doc, cmds_undoc, help_topics = self._build_command_info() diff --git a/cmd2/parsing.py b/cmd2/parsing.py index 657db32c..c420e9aa 100755 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -91,9 +91,8 @@ class Statement(str): Tips: 1. `argparse <https://docs.python.org/3/library/argparse.html>`_ is your - friend for anything complex. ``cmd2`` has two decorators - (:func:`~cmd2.decorators.with_argparser`, and - :func:`~cmd2.decorators.with_argparser_and_unknown_args`) which you can + friend for anything complex. ``cmd2`` has the decorator + (:func:`~cmd2.decorators.with_argparser`) which you can use to make your command method receive a namespace of parsed arguments, whether positional or denoted with switches. diff --git a/cmd2/utils.py b/cmd2/utils.py index cd716083..7c5f1560 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -281,17 +281,29 @@ def natural_sort(list_to_sort: Iterable[str]) -> List[str]: return sorted(list_to_sort, key=natural_keys) -def unquote_specific_tokens(args: List[str], tokens_to_unquote: List[str]) -> None: +def quote_specific_tokens(tokens: List[str], tokens_to_quote: List[str]) -> None: """ - Unquote a specific tokens in a list of command-line arguments - This is used when certain tokens have to be passed to another command - :param args: the command line args - :param tokens_to_unquote: the tokens, which if present in args, to unquote + Quote specific tokens in a list + + :param tokens: token list being edited + :param tokens_to_quote: the tokens, which if present in tokens, to quote + """ + for i, token in enumerate(tokens): + if token in tokens_to_quote: + tokens[i] = quote_string(token) + + +def unquote_specific_tokens(tokens: List[str], tokens_to_unquote: List[str]) -> None: + """ + Unquote specific tokens in a list + + :param tokens: token list being edited + :param tokens_to_unquote: the tokens, which if present in tokens, to unquote """ - for i, arg in enumerate(args): - unquoted_arg = strip_quotes(arg) - if unquoted_arg in tokens_to_unquote: - args[i] = unquoted_arg + for i, token in enumerate(tokens): + unquoted_token = strip_quotes(token) + if unquoted_token in tokens_to_unquote: + tokens[i] = unquoted_token def expand_user(token: str) -> str: diff --git a/docs/api/utils.rst b/docs/api/utils.rst index 9276587f..81c978c9 100644 --- a/docs/api/utils.rst +++ b/docs/api/utils.rst @@ -22,6 +22,10 @@ Quote Handling .. autofunction:: cmd2.utils.strip_quotes +.. autofunction:: cmd2.utils.quote_specific_tokens + +.. autofunction:: cmd2.utils.unquote_specific_tokens + IO Handling ----------- diff --git a/docs/features/argument_processing.rst b/docs/features/argument_processing.rst index 9abd9c65..bcc68633 100644 --- a/docs/features/argument_processing.rst +++ b/docs/features/argument_processing.rst @@ -35,7 +35,6 @@ applications. passed to commands: * :func:`cmd2.decorators.with_argparser` -* :func:`cmd2.decorators.with_argparser_and_unknown_args` * :func:`cmd2.decorators.with_argument_list` All of these decorators accept an optional **preserve_quotes** argument which @@ -262,12 +261,12 @@ Unknown Positional Arguments If you want all unknown arguments to be passed to your command as a list of strings, then decorate the command method with the -``@with_argparser_and_unknown_args`` decorator. +``@with_argparser(..., with_unknown_args=True)`` decorator. Here's what it looks like:: import argparse - from cmd2 import with_argparser_and_unknown_args + from cmd2 import with_argparser dir_parser = argparse.ArgumentParser() dir_parser.add_argument('-l', '--long', action='store_true', help="display in long format with one item per line") @@ -292,9 +291,8 @@ Using A Custom Namespace In some cases, it may be necessary to write custom ``argparse`` code that is dependent on state data of your application. To support this ability while -still allowing use of the decorators, both ``@with_argparser`` and -``@with_argparser_and_unknown_args`` have an optional argument called -``ns_provider``. +still allowing use of the decorators, ``@with_argparser`` has an optional +argument called ``ns_provider``. ``ns_provider`` is a Callable that accepts a ``cmd2.Cmd`` object as an argument and returns an ``argparse.Namespace``:: @@ -320,9 +318,8 @@ logic. Subcommands ------------ -Subcommands are supported for commands using either the ``@with_argparser`` or -``@with_argparser_and_unknown_args`` decorator. The syntax for supporting them -is based on argparse sub-parsers. +Subcommands are supported for commands using the ``@with_argparser`` decorator. +The syntax is based on argparse sub-parsers. You may add multiple layers of subcommands for your command. ``cmd2`` will automatically traverse and tab complete subcommands for all commands using @@ -350,8 +347,8 @@ help output. Decorator Order --------------- -If you are using custom decorators in combination with either -``@cmd2.with_argparser`` or ``@cmd2.with_argparser_and_unknown_args``, then the +If you are using custom decorators in combination with +``@cmd2.with_argparser``, then the order of your custom decorator(s) relative to the ``cmd2`` decorator matters when it comes to runtime behavior and ``argparse`` errors. There is nothing ``cmd2``-specific here, this is just a side-effect of how decorators work in diff --git a/docs/features/modular_commands.rst b/docs/features/modular_commands.rst index 6159bc41..790b933e 100644 --- a/docs/features/modular_commands.rst +++ b/docs/features/modular_commands.rst @@ -127,7 +127,7 @@ CommandSets and pass in the constructor to Cmd2. Dynamic Commands ~~~~~~~~~~~~~~~~ -You man also dynamically load and unload commands by installing and removing CommandSets at runtime. For example, +You can also dynamically load and unload commands by installing and removing CommandSets at runtime. For example, if you could support runtime loadable plugins or add/remove commands based on your state. You may need to disable command auto-loading if you need dynamically load commands at runtime. diff --git a/docs/features/settings.rst b/docs/features/settings.rst index aa3e5cec..c21b3258 100644 --- a/docs/features/settings.rst +++ b/docs/features/settings.rst @@ -134,9 +134,9 @@ changes a setting, and will receive both the old value and the new value. .. code-block:: text - (Cmd) set --long | grep sunny + (Cmd) set --verbose | grep sunny sunny: False # Is it sunny outside? - (Cmd) set --long | grep degrees + (Cmd) set --verbose | grep degrees degrees_c: 22 # Temperature in Celsius (Cmd) sunbathe Too dim. @@ -31,14 +31,13 @@ Programming Language :: Python :: Implementation :: CPython Topic :: Software Development :: Libraries :: Python Modules """.splitlines()))) # noqa: E128 -SETUP_REQUIRES = ['setuptools_scm >= 3.0'] +SETUP_REQUIRES = ['setuptools >= 34.4', 'setuptools_scm >= 3.0'] INSTALL_REQUIRES = [ 'attrs >= 16.3.0', 'colorama >= 0.3.7', 'importlib_metadata>=1.6.0;python_version<"3.8"', 'pyperclip >= 1.6', - 'setuptools >= 34.4', 'wcwidth >= 0.1.7', ] diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index d913b4fc..a8f2f993 100755 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -981,6 +981,16 @@ class HelpApp(cmd2.Cmd): def do_undoc(self, arg): pass + def do_multiline_docstr(self, arg): + """ + This documentation + is multiple lines + and there are no + tabs + """ + pass + + @pytest.fixture def help_app(): app = HelpApp() @@ -1004,6 +1014,11 @@ def test_help_overridden_method(help_app): expected = normalize('This overrides the edit command and does nothing.') assert out == expected +def test_help_multiline_docstring(help_app): + out, err = run_cmd(help_app, 'help multiline_docstr') + expected = normalize('This documentation\nis multiple lines\nand there are no\ntabs') + assert out == expected + class HelpCategoriesApp(cmd2.Cmd): """Class for testing custom help_* methods which override docstring help.""" @@ -1675,16 +1690,17 @@ def test_alias_create(base_app): out, err = run_cmd(base_app, 'alias list --with_silent fake') assert out == normalize('alias create --silent fake set') -def test_alias_create_with_quoted_value(base_app): - """Demonstrate that quotes in alias value will be preserved (except for redirectors and terminators)""" +def test_alias_create_with_quoted_tokens(base_app): + """Demonstrate that quotes in alias value will be preserved""" + create_command = 'alias create fake help ">" "out file.txt" ";"' # Create the alias - out, err = run_cmd(base_app, 'alias create fake help ">" "out file.txt" ";"') + out, err = run_cmd(base_app, create_command) assert out == normalize("Alias 'fake' created") - # Look up the new alias (Only the redirector should be unquoted) + # Look up the new alias and verify all quotes are preserved out, err = run_cmd(base_app, 'alias list fake') - assert out == normalize('alias create fake help > "out file.txt" ;') + assert out == normalize(create_command) @pytest.mark.parametrize('alias_name', invalid_command_name) def test_alias_create_invalid_name(base_app, alias_name, capsys): @@ -1784,15 +1800,17 @@ def test_macro_create(base_app): out, err = run_cmd(base_app, 'macro list --with_silent fake') assert out == normalize('macro create --silent fake set') -def test_macro_create_with_quoted_value(base_app): - """Demonstrate that quotes in macro value will be preserved (except for redirectors and terminators)""" +def test_macro_create_with_quoted_tokens(base_app): + """Demonstrate that quotes in macro value will be preserved""" + create_command = 'macro create fake help ">" "out file.txt" ";"' + # Create the macro - out, err = run_cmd(base_app, 'macro create fake help ">" "out file.txt" ";"') + out, err = run_cmd(base_app, create_command) assert out == normalize("Macro 'fake' created") - # Look up the new macro (Only the redirector should be unquoted) + # Look up the new macro and verify all quotes are preserved out, err = run_cmd(base_app, 'macro list fake') - assert out == normalize('macro create fake help > "out file.txt" ;') + assert out == normalize(create_command) @pytest.mark.parametrize('macro_name', invalid_command_name) def test_macro_create_invalid_name(base_app, macro_name): |