diff options
-rw-r--r-- | CHANGELOG.md | 3 | ||||
-rw-r--r-- | cmd2/cmd2.py | 35 | ||||
-rw-r--r-- | cmd2/parsing.py | 92 | ||||
-rw-r--r-- | docs/argument_processing.rst | 4 | ||||
-rwxr-xr-x | examples/decorator_example.py | 16 | ||||
-rwxr-xr-x | setup.py | 45 | ||||
-rw-r--r-- | tests/test_parsing.py | 7 |
7 files changed, 107 insertions, 95 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f478cb2..694d2786 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ also be colored. * `help_error` - the error that prints when no help information can be found * `default_error` - the error that prints when a non-existent command is run + * The `with_argparser` decorators now add the Statement object created when parsing the command line to the + `argparse.Namespace` object they pass to the `do_*` methods. It is stored in an attribute called `__statement__`. + This can be useful if a command function needs to know the command line for things like logging. * Potentially breaking changes * The following commands now write to stderr instead of stdout when printing an error. This will make catching errors easier in pyscript. diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index d3028961..c1cebdd2 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -49,7 +49,7 @@ from . import utils from .argparse_completer import AutoCompleter, ACArgumentParser, ACTION_ARG_CHOICES from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer from .history import History, HistoryItem -from .parsing import StatementParser, Statement, Macro, MacroArg, shlex_split, get_command_arg_list +from .parsing import StatementParser, Statement, Macro, MacroArg, shlex_split # Set up readline from .rl_utils import rl_type, RlType, rl_get_point, rl_set_prompt, vt100_support, rl_make_safe_prompt @@ -175,9 +175,13 @@ def with_argument_list(*args: List[Callable], preserve_quotes: bool = False) -> def arg_decorator(func: Callable): @functools.wraps(func) def cmd_wrapper(cmd2_instance, statement: Union[Statement, str]): - parsed_arglist = get_command_arg_list(statement, preserve_quotes) + _, parsed_arglist = cmd2_instance.statement_parser.get_command_arg_list(command_name, + statement, + preserve_quotes) + return func(cmd2_instance, parsed_arglist) + command_name = func.__name__[len(COMMAND_FUNC_PREFIX):] cmd_wrapper.__doc__ = func.__doc__ return cmd_wrapper @@ -194,7 +198,10 @@ def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser, preserve :param argparser: unique instance of ArgumentParser :param preserve_quotes: if True, then arguments passed to argparse maintain their quotes - :return: function that gets passed argparse-parsed args and a list of unknown argument strings + :return: function that gets passed argparse-parsed args in a Namespace and a list of unknown argument strings + A member called __statement__ is added to the Namespace to provide command functions access to the + Statement object. This can be useful if the command function needs to know the command line. + """ import functools @@ -202,18 +209,22 @@ def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser, preserve def arg_decorator(func: Callable): @functools.wraps(func) def cmd_wrapper(cmd2_instance, statement: Union[Statement, str]): - parsed_arglist = get_command_arg_list(statement, preserve_quotes) + statement, parsed_arglist = cmd2_instance.statement_parser.get_command_arg_list(command_name, + statement, + preserve_quotes) try: args, unknown = argparser.parse_known_args(parsed_arglist) except SystemExit: return else: + setattr(args, '__statement__', statement) return func(cmd2_instance, args, unknown) # argparser defaults the program name to sys.argv[0] # we want it to be the name of our command - argparser.prog = func.__name__[len(COMMAND_FUNC_PREFIX):] + command_name = func.__name__[len(COMMAND_FUNC_PREFIX):] + argparser.prog = command_name # If the description has not been set, then use the method docstring if one exists if argparser.description is None and func.__doc__: @@ -237,7 +248,9 @@ def with_argparser(argparser: argparse.ArgumentParser, :param argparser: unique instance of ArgumentParser :param preserve_quotes: if True, then arguments passed to argparse maintain their quotes - :return: function that gets passed the argparse-parsed args + :return: function that gets passed the argparse-parsed args in a Namespace + A member called __statement__ is added to the Namespace to provide command functions access to the + Statement object. This can be useful if the command function needs to know the command line. """ import functools @@ -245,19 +258,21 @@ def with_argparser(argparser: argparse.ArgumentParser, def arg_decorator(func: Callable): @functools.wraps(func) def cmd_wrapper(cmd2_instance, statement: Union[Statement, str]): - - parsed_arglist = get_command_arg_list(statement, preserve_quotes) - + statement, parsed_arglist = cmd2_instance.statement_parser.get_command_arg_list(command_name, + statement, + preserve_quotes) try: args = argparser.parse_args(parsed_arglist) except SystemExit: return else: + setattr(args, '__statement__', statement) return func(cmd2_instance, args) # argparser defaults the program name to sys.argv[0] # we want it to be the name of our command - argparser.prog = func.__name__[len(COMMAND_FUNC_PREFIX):] + command_name = func.__name__[len(COMMAND_FUNC_PREFIX):] + argparser.prog = command_name # If the description has not been set, then use the method docstring if one exists if argparser.description is None and func.__doc__: diff --git a/cmd2/parsing.py b/cmd2/parsing.py index cd81f250..2dc698b0 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -236,34 +236,6 @@ class Statement(str): return rtn -def get_command_arg_list(to_parse: Union[Statement, str], preserve_quotes: bool) -> List[str]: - """ - Called by the argument_list and argparse wrappers to retrieve just the arguments being - passed to their do_* methods as a list. - - :param to_parse: what is being passed to the do_* method. It can be one of two types: - 1. An already parsed Statement - 2. An argument string in cases where a do_* method is explicitly called - e.g.: Calling do_help('alias create') would cause to_parse to be 'alias create' - - :param preserve_quotes: if True, then quotes will not be stripped from the arguments - :return: the arguments in a list - """ - if isinstance(to_parse, Statement): - # In the case of a Statement, we already have what we need - if preserve_quotes: - return to_parse.arg_list - else: - return to_parse.argv[1:] - else: - # We have the arguments in a string. Use shlex to split it. - parsed_arglist = shlex_split(to_parse) - if not preserve_quotes: - parsed_arglist = [utils.strip_quotes(arg) for arg in parsed_arglist] - - return parsed_arglist - - class StatementParser: """Parse raw text into command components. @@ -382,16 +354,22 @@ class StatementParser: errmsg = '' return valid, errmsg - def tokenize(self, line: str) -> List[str]: - """Lex a string into a list of tokens. - - shortcuts and aliases are expanded and comments are removed - - Raises ValueError if there are unclosed quotation marks. + def tokenize(self, line: str, expand: bool = True) -> List[str]: + """ + Lex a string into a list of tokens. Shortcuts and aliases are expanded and comments are removed + + :param line: the command line being lexed + :param expand: If True, then aliases and shortcuts will be expanded. + Set this to False if no expansion should occur because the command name is already known. + Otherwise the command could be expanded if it matched an alias name. This is for cases where + a do_* method was called manually (e.g do_help('alias'). + :return: A list of tokens + :raises ValueError if there are unclosed quotation marks. """ # expand shortcuts and aliases - line = self._expand(line) + if expand: + line = self._expand(line) # check if this line is a comment if line.strip().startswith(constants.COMMENT_CHAR): @@ -404,12 +382,19 @@ class StatementParser: tokens = self._split_on_punctuation(tokens) return tokens - def parse(self, line: str) -> Statement: - """Tokenize the input and parse it into a Statement object, stripping + def parse(self, line: str, expand: bool = True) -> Statement: + """ + Tokenize the input and parse it into a Statement object, stripping comments, expanding aliases and shortcuts, and extracting output redirection directives. - Raises ValueError if there are unclosed quotation marks. + :param line: the command line being parsed + :param expand: If True, then aliases and shortcuts will be expanded. + Set this to False if no expansion should occur because the command name is already known. + Otherwise the command could be expanded if it matched an alias name. This is for cases where + a do_* method was called manually (e.g do_help('alias'). + :return: A parsed Statement + :raises ValueError if there are unclosed quotation marks """ # handle the special case/hardcoded terminator of a blank line @@ -424,7 +409,7 @@ class StatementParser: arg_list = [] # lex the input into a list of tokens - tokens = self.tokenize(line) + tokens = self.tokenize(line, expand) # of the valid terminators, find the first one to occur in the input terminator_pos = len(tokens) + 1 @@ -605,6 +590,35 @@ class StatementParser: ) return statement + def get_command_arg_list(self, command_name: str, to_parse: Union[Statement, str], + preserve_quotes: bool) -> Tuple[Statement, List[str]]: + """ + Called by the argument_list and argparse wrappers to retrieve just the arguments being + passed to their do_* methods as a list. + + :param command_name: name of the command being run + :param to_parse: what is being passed to the do_* method. It can be one of two types: + 1. An already parsed Statement + 2. An argument string in cases where a do_* method is explicitly called + e.g.: Calling do_help('alias create') would cause to_parse to be 'alias create' + + In this case, the string will be converted to a Statement and returned along + with the argument list. + + :param preserve_quotes: if True, then quotes will not be stripped from the arguments + :return: A tuple containing: + The Statement used to retrieve the arguments + The argument list + """ + # Check if to_parse needs to be converted to a Statement + if not isinstance(to_parse, Statement): + to_parse = self.parse(command_name + ' ' + to_parse, expand=False) + + if preserve_quotes: + return to_parse, to_parse.arg_list + else: + return to_parse, to_parse.argv[1:] + def _expand(self, line: str) -> str: """Expand shortcuts and aliases""" diff --git a/docs/argument_processing.rst b/docs/argument_processing.rst index bad683bf..fc1f2433 100644 --- a/docs/argument_processing.rst +++ b/docs/argument_processing.rst @@ -9,7 +9,9 @@ Argument Processing 1. Parsing input and quoted strings like the Unix shell 2. Parse the resulting argument list using an instance of ``argparse.ArgumentParser`` that you provide -3. Passes the resulting ``argparse.Namespace`` object to your command function +3. Passes the resulting ``argparse.Namespace`` object to your command function. The ``Namespace`` includes the + ``Statement`` object that was created when parsing the command line. It is stored in the ``__statement__`` + attribute of the ``Namespace``. 4. Adds the usage message from the argument parser to your command. 5. Checks if the ``-h/--help`` option is present, and if so, display the help message for the command diff --git a/examples/decorator_example.py b/examples/decorator_example.py index cf948d1d..d8088c0a 100755 --- a/examples/decorator_example.py +++ b/examples/decorator_example.py @@ -12,6 +12,7 @@ verifying that the output produced matches the transcript. """ import argparse import sys +from typing import List import cmd2 @@ -46,7 +47,7 @@ class CmdLineApp(cmd2.Cmd): speak_parser.add_argument('words', nargs='+', help='words to say') @cmd2.with_argparser(speak_parser) - def do_speak(self, args): + def do_speak(self, args: argparse.Namespace): """Repeats what you tell me to.""" words = [] for word in args.words: @@ -67,13 +68,18 @@ class CmdLineApp(cmd2.Cmd): tag_parser.add_argument('content', nargs='+', help='content to surround with tag') @cmd2.with_argparser(tag_parser) - def do_tag(self, args): - """create a html tag""" + def do_tag(self, args: argparse.Namespace): + """create an html tag""" + # The Namespace always includes the Statement object created when parsing the command line + statement = args.__statement__ + + self.poutput("The command line you ran was: {}".format(statement.command_and_args)) + self.poutput("It generated this tag:") self.poutput('<{0}>{1}</{0}>'.format(args.tag, ' '.join(args.content))) @cmd2.with_argument_list - def do_tagg(self, arglist): - """verion of creating an html tag using arglist instead of argparser""" + def do_tagg(self, arglist: List[str]): + """version of creating an html tag using arglist instead of argparser""" if len(arglist) >= 2: tag = arglist[0] content = arglist[1:] @@ -3,49 +3,13 @@ """ Setuptools setup file, used to install or test 'cmd2' """ +import codecs from setuptools import setup -DESCRIPTION = "cmd2 - a tool for building interactive command line applications in Python" -LONG_DESCRIPTION = """cmd2 is a tool for building interactive command line applications in Python. Its goal is to make -it quick and easy for developers to build feature-rich and user-friendly interactive command line applications. It -provides a simple API which is an extension of Python's built-in cmd module. cmd2 provides a wealth of features on top -of cmd to make your life easier and eliminates much of the boilerplate code which would be necessary when using cmd. +DESCRIPTION = "cmd2 - quickly build feature-rich and user-friendly interactive command line applications in Python" -The latest documentation for cmd2 can be read online here: -https://cmd2.readthedocs.io/ - -Main features: - - - Searchable command history (`history` command and `<Ctrl>+r`) - optionally persistent - - Text file scripting of your application with `load` (`@`) and `_relative_load` (`@@`) - - Python scripting of your application with ``pyscript`` - - Run shell commands with ``!`` - - Pipe command output to shell commands with `|` - - Redirect command output to file with `>`, `>>` - - Bare `>`, `>>` with no filename send output to paste buffer (clipboard) - - `py` enters interactive Python console (opt-in `ipy` for IPython console) - - Option to display long output using a pager with ``cmd2.Cmd.ppaged()`` - - Multi-line commands - - Special-character command shortcuts (beyond cmd's `?` and `!`) - - Command aliasing similar to bash `alias` command - - Macros, which are similar to aliases, but they can contain argument placeholders - - Ability to load commands at startup from an initialization script - - Settable environment parameters - - Parsing commands with arguments using `argparse`, including support for sub-commands - - Unicode character support - - Good tab-completion of commands, sub-commands, file system paths, and shell commands - - Automatic tab-completion of `argparse` flags when using one of the `cmd2` `argparse` decorators - - Support for Python 3.4+ on Windows, macOS, and Linux - - Trivial to provide built-in help for all commands - - Built-in regression testing framework for your applications (transcript-based testing) - - Transcripts for use with built-in regression can be automatically generated from `history -t` - - Alerts that seamlessly print while user enters text at prompt - -Usable without modification anywhere cmd is used; simply import cmd2.Cmd in place of cmd.Cmd. - -Version 0.9.0+ of cmd2 supports Python 3.4+ only. If you wish to use cmd2 with Python 2.7, then -please install version 0.8.x. -""" +with codecs.open('README.md', encoding='utf8') as f: + LONG_DESCRIPTION = f.read() CLASSIFIERS = list(filter(None, map(str.strip, """ @@ -90,6 +54,7 @@ setup( use_scm_version=True, description=DESCRIPTION, long_description=LONG_DESCRIPTION, + long_description_content_type='text/markdown', classifiers=CLASSIFIERS, author='Catherine Devlin', author_email='catherine.devlin@gmail.com', diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 85ee0765..8cea3305 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -471,11 +471,18 @@ def test_empty_statement_raises_exception(): ('l', 'shell', 'ls -al') ]) def test_parse_alias_and_shortcut_expansion(parser, line, command, args): + # Test first with expansion statement = parser.parse(line) assert statement.command == command assert statement == args assert statement.args == statement + # Now allow no expansion + statement = parser.parse(line, expand=False) + assert statement.command == line.split()[0] + assert statement.split() == line.split()[1:] + assert statement.args == statement + def test_parse_alias_on_multiline_command(parser): line = 'anothermultiline has > inside an unfinished command' statement = parser.parse(line) |