diff options
author | Todd Leonhardt <todd.leonhardt@gmail.com> | 2018-01-22 21:07:06 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-01-22 21:07:06 -0500 |
commit | ddfd3d9a400ae81468e9abcc89fe690c30b7ec7f (patch) | |
tree | 720e9b58b694dff8f8c2513918e16a11ea24321f | |
parent | 7b564b4424accfbd7439de10a169d9b64bc599c5 (diff) | |
parent | 504e3dbf9e15faf34611aae8ddabecb90e86eda5 (diff) | |
download | cmd2-git-ddfd3d9a400ae81468e9abcc89fe690c30b7ec7f.tar.gz |
Merge pull request #257 from python-cmd2/sub-commands
Sub-commands and automatic transcript generation
-rw-r--r-- | CHANGELOG.md | 23 | ||||
-rwxr-xr-x | README.md | 11 | ||||
-rwxr-xr-x | cmd2.py | 201 | ||||
-rw-r--r-- | docs/argument_processing.rst | 29 | ||||
-rw-r--r-- | docs/conf.py | 4 | ||||
-rw-r--r-- | docs/install.rst | 5 | ||||
-rwxr-xr-x | examples/arg_print.py | 4 | ||||
-rwxr-xr-x | examples/argparse_example.py | 6 | ||||
-rwxr-xr-x | examples/example.py | 6 | ||||
-rwxr-xr-x | examples/pirate.py | 20 | ||||
-rwxr-xr-x | examples/subcommands.py | 61 | ||||
-rw-r--r-- | examples/transcripts/exampleSession.txt (renamed from examples/exampleSession.txt) | 0 | ||||
-rw-r--r-- | examples/transcripts/pirate.transcript | 10 | ||||
-rw-r--r-- | examples/transcripts/transcript_regex.txt (renamed from examples/transcript_regex.txt) | 0 | ||||
-rwxr-xr-x | setup.py | 12 | ||||
-rw-r--r-- | tests/conftest.py | 9 | ||||
-rw-r--r-- | tests/test_argparse.py | 90 | ||||
-rw-r--r-- | tests/test_cmd2.py | 20 | ||||
-rw-r--r-- | tests/test_completion.py | 217 |
19 files changed, 650 insertions, 78 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index c017ff36..24c11dec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,33 +1,40 @@ ## 0.8.0 (TBD, 2018) * Bug Fixes * Fixed unit tests on Python 3.7 due to changes in how re.escape() behaves in Python 3.7 + * Fixed a bug where unknown commands were getting saved in the history * Enhancements * Three new decorators for **do_*** commands to make argument parsing easier * **with_argument_list** decorator to change argument type from str to List[str] * **do_*** commands get a single argument which is a list of strings, as pre-parsed by shlex.split() * **with_argument_parser** decorator for strict argparse-based argument parsing of command arguments * **do_*** commands get a single argument which is the output of argparse.parse_args() - * **with_argparser_and_unknown_args** decorator for argparse-based argument parsing, but allowing unknown args + * **with_argparser_and_unknown_args** decorator for argparse-based argument parsing, but allows unknown args * **do_*** commands get two arguments, the output of argparse.parse_known_args() - * See the **Argument Processing** section of the documentation for more information on these decorators - * Alternatively, see the **argparse_example.py** and **arg_print.py** examples + * See the [Argument Processing](http://cmd2.readthedocs.io/en/latest/argument_processing.html) section of the documentation for more information on these decorators + * Alternatively, see the [argparse_example.py](https://github.com/python-cmd2/cmd2/blob/master/examples/argpasre_example.py) + and [arg_print.py](https://github.com/python-cmd2/cmd2/blob/master/examples/arg_print.py) examples + * Added support for Argpasre sub-commands when using the **with_argument_parser** or **with_argparser_and_unknown_args** decorators + * See [subcommands.py](https://github.com/python-cmd2/cmd2/blob/master/examples/subcommands.py) for an example of how to use subcommands + * Tab-completion of sub-command names is automatically supported * The **__relative_load** command is now hidden from the help menu by default * This command is not intended to be called from the command line, only from within scripts * The **set** command now has an additional **-a/--all** option to also display read-only settings - * The **history** command can now run, edit, and save prior commands, in addition to the prior behavior of displaying prior commands. + * The **history** command can now run, edit, and save prior commands, in addition to displaying prior commands. + * The **history** command can now automatically generate a transcript file for regression testing + * This feature works imperfectly at the moment, but it is still quite useful * Commands Removed * The **cmdenvironment** has been removed and its functionality incorporated into the **-a/--all** argument to **set** * The **show** command has been removed. Its functionality has always existing within **set** and continues to do so - * The **save** command has been removed. The capability to save prior commands is now part of the **history** command. + * The **save** command has been removed. The capability to save commands is now part of the **history** command. * The **run** command has been removed. The capability to run prior commands is now part of the **history** command. * Other changes * The **edit** command no longer allows you to edit prior commands. The capability to edit prior commands is now part of the **history** command. The **edit** command still allows you to edit arbitrary files. * the **autorun_on_edit** setting has been removed. + * For Python 3.4 and earlier, ``cmd2`` now has an additional dependency on the ``contextlib2`` module * Deprecations * The old **options** decorator for optparse-based argument parsing is now *deprecated* - * The old decorator is still present for now, but will eventually be removed in a future release - * ``cmd2`` no longer includes **optparse.make_option** so if your app needs it you need to import it directly from optparse - + * The old decorator is still present for now, but will be removed in a future release + * ``cmd2`` no longer includes **optparse.make_option**, so if your app needs it import directly from optparse ## 0.7.9 (January 4, 2018) @@ -29,11 +29,11 @@ Main Features - Multi-line, case-insensitive, and abbreviated commands - Special-character command shortcuts (beyond cmd's `@` and `!`) - Settable environment parameters -- Parsing commands with arguments using `argparse` +- Parsing commands with arguments using `argparse`, including support for sub-commands - Unicode character support (*Python 3 only*) -- Good tab-completion of commands, file system paths, and shell commands +- Good tab-completion of commands, sub-commands, file system paths, and shell commands - Python 2.7 and 3.4+ support -- Linux, macOS and Windows support +- Windows, macOS, and Linux support - Trivial to provide built-in help for all commands - Built-in regression testing framework for your applications (transcript-based testing) @@ -48,8 +48,9 @@ pip install -U cmd2 cmd2 works with Python 2.7 and Python 3.4+ on Windows, macOS, and Linux. It is pure Python code with the only 3rd-party dependencies being on [six](https://pypi.python.org/pypi/six), -[pyparsing](http://pyparsing.wikispaces.com), and [pyperclip](https://github.com/asweigart/pyperclip) -(on Windows, [pyreadline](https://pypi.python.org/pypi/pyreadline) is an additional dependency). +[pyparsing](http://pyparsing.wikispaces.com), and [pyperclip](https://github.com/asweigart/pyperclip). +Windows has an additional dependency on [pyreadline](https://pypi.python.org/pypi/pyreadline) and Python +3.4 and earlier have an additional dependency on [contextlib2](https://pypi.python.org/pypi/contextlib2). For information on other installation options, see [Installation Instructions](https://cmd2.readthedocs.io/en/latest/install.html) in the cmd2 @@ -73,6 +73,12 @@ try: except ImportError: import subprocess +# Python 3.4 and earlier require contextlib2 for temporarily redirecting stderr and stdout +if sys.version_info < (3, 5): + from contextlib2 import redirect_stdout, redirect_stderr +else: + from contextlib import redirect_stdout, redirect_stderr + # Detect whether IPython is installed to determine if the built-in "ipy" command should be included ipython_available = True try: @@ -106,7 +112,7 @@ if six.PY2 and sys.platform.startswith('lin'): except ImportError: pass -__version__ = '0.8.0a' +__version__ = '0.8.0' # Pyparsing enablePackrat() can greatly speed up parsing, but problems have been seen in Python 3 in the past pyparsing.ParserElement.enablePackrat() @@ -272,10 +278,13 @@ def with_argument_list(func): return cmd_wrapper -def with_argparser_and_unknown_args(argparser): - """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. +def with_argparser_and_unknown_args(argparser, subcommand_names=None): + """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: argparse.ArgumentParser - given instance of ArgumentParser + :param subcommand_names: List[str] - list of subcommand names for this parser (used for tab-completion) + :return: function that gets passed parsed args and a list of unknown args """ def arg_decorator(func): def cmd_wrapper(instance, cmdline): @@ -292,14 +301,26 @@ def with_argparser_and_unknown_args(argparser): argparser.description = func.__doc__ cmd_wrapper.__doc__ = argparser.format_help() + + # Mark this function as having an argparse ArgumentParser (used by do_help) + cmd_wrapper.__dict__['has_parser'] = True + + # If there are subcommands, store their names to support tab-completion of subcommand names + if subcommand_names is not None: + cmd_wrapper.__dict__['subcommand_names'] = subcommand_names + return cmd_wrapper + return arg_decorator -def with_argument_parser(argparser): - """A decorator to alter a cmd2 method to populate its ``args`` - argument by parsing arguments with the given instance of - argparse.ArgumentParser. +def with_argparser(argparser, subcommand_names=None): + """A decorator to alter a cmd2 method to populate its ``args`` argument by parsing arguments + with the given instance of argparse.ArgumentParser. + + :param argparser: argparse.ArgumentParser - given instance of ArgumentParser + :param subcommand_names: List[str] - list of subcommand names for this parser (used for tab-completion) + :return: function that gets passed parsed args """ def arg_decorator(func): def cmd_wrapper(instance, cmdline): @@ -316,7 +337,16 @@ def with_argument_parser(argparser): argparser.description = func.__doc__ cmd_wrapper.__doc__ = argparser.format_help() + + # Mark this function as having an argparse ArgumentParser (used by do_help) + cmd_wrapper.__dict__['has_parser'] = True + + # If there are subcommands, store their names to support tab-completion of subcommand names + if subcommand_names is not None: + cmd_wrapper.__dict__['subcommand_names'] = subcommand_names + return cmd_wrapper + return arg_decorator @@ -644,6 +674,9 @@ class Cmd(cmd.Cmd): # Used when piping command output to a shell command self.pipe_proc = None + # Used by complete() for readline tab completion + self.completion_matches = [] + # ----- Methods related to presenting output to the user ----- @property @@ -733,7 +766,7 @@ class Cmd(cmd.Cmd): # noinspection PyMethodOverriding def completenames(self, text, line, begidx, endidx): - """Override of cmd2 method which completes command names both for command completion and help.""" + """Override of cmd method which completes command names both for command completion and help.""" command = text if self.case_insensitive: command = text.lower() @@ -747,6 +780,91 @@ class Cmd(cmd.Cmd): return cmd_completion + # noinspection PyUnusedLocal + def complete_subcommand(self, text, line, begidx, endidx): + """Readline tab-completion method for completing argparse sub-command names.""" + command, args, foo = self.parseline(line) + arglist = args.split() + + if len(arglist) <= 1 and command + ' ' + args == line: + funcname = self._func_named(command) + if funcname: + # Check to see if this function was decorated with an argparse ArgumentParser + func = getattr(self, funcname) + subcommand_names = func.__dict__.get('subcommand_names', None) + + # If this command has subcommands + if subcommand_names is not None: + arg = '' + if arglist: + arg = arglist[0] + + matches = [sc for sc in subcommand_names if sc.startswith(arg)] + + # If completing the sub-command name and get exactly 1 result and are at end of line, add a space + if len(matches) == 1 and endidx == len(line): + matches[0] += ' ' + return matches + + return [] + + def complete(self, text, state): + """Override of command method which returns the next possible completion for 'text'. + + If a command has not been entered, then complete against command list. + Otherwise try to call complete_<command> to get list of completions. + + This method gets called directly by readline because it is set as the tab-completion function. + + This completer function is called as complete(text, state), for state in 0, 1, 2, …, until it returns a + non-string value. It should return the next possible completion starting with text. + + :param text: str - the current word that user is typing + :param state: int - non-negative integer + """ + if state == 0: + import readline + origline = readline.get_line_buffer() + line = origline.lstrip() + stripped = len(origline) - len(line) + begidx = readline.get_begidx() - stripped + endidx = readline.get_endidx() - stripped + if begidx > 0: + command, args, foo = self.parseline(line) + if command == '': + compfunc = self.completedefault + else: + arglist = args.split() + + compfunc = None + # If the user has entered no more than a single argument after the command name + if len(arglist) <= 1 and command + ' ' + args == line: + funcname = self._func_named(command) + if funcname: + # Check to see if this function was decorated with an argparse ArgumentParser + func = getattr(self, funcname) + subcommand_names = func.__dict__.get('subcommand_names', None) + + # If this command has subcommands + if subcommand_names is not None: + compfunc = self.complete_subcommand + + if compfunc is None: + # This command either doesn't have sub-commands or the user is past the point of entering one + try: + compfunc = getattr(self, 'complete_' + command) + except AttributeError: + compfunc = self.completedefault + else: + compfunc = self.completenames + + self.completion_matches = compfunc(text, line, begidx, endidx) + + try: + return self.completion_matches[state] + except IndexError: + return None + def precmd(self, statement): """Hook method executed just before the command is processed by ``onecmd()`` and after adding it to the history. @@ -854,8 +972,7 @@ class Cmd(cmd.Cmd): (stop, statement) = self.postparsing_precmd(statement) if stop: return self.postparsing_postcmd(stop) - if statement.parsed.command not in self.excludeFromHistory: - self.history.append(statement.parsed.raw) + try: if self.allow_redirection: self._redirect_output(statement) @@ -904,7 +1021,11 @@ class Cmd(cmd.Cmd): self.cmdqueue = list(cmds) + self.cmdqueue try: while self.cmdqueue and not stop: - stop = self.onecmd_plus_hooks(self.cmdqueue.pop(0)) + line = self.cmdqueue.pop(0) + if self.echo and line != 'eos': + self.poutput('{}{}'.format(self.prompt, line)) + + stop = self.onecmd_plus_hooks(line) finally: # Clear out the command queue and script directory stack, just in # case we hit an error and they were not completed. @@ -1046,6 +1167,10 @@ class Cmd(cmd.Cmd): if not funcname: return self.default(statement) + # Since we have a valid command store it in the history + if statement.parsed.command not in self.excludeFromHistory: + self.history.append(statement.parsed.raw) + try: func = getattr(self, funcname) except AttributeError: @@ -1198,8 +1323,20 @@ class Cmd(cmd.Cmd): # Getting help for a specific command funcname = self._func_named(arglist[0]) if funcname: - # No special behavior needed, delegate to cmd base class do_help() - cmd.Cmd.do_help(self, funcname[3:]) + # Check to see if this function was decorated with an argparse ArgumentParser + func = getattr(self, funcname) + if func.__dict__.get('has_parser', False): + # Function has an argparser, so get help based on all the arguments in case there are sub-commands + new_arglist = arglist[1:] + new_arglist.append('-h') + + # Temporarily redirect all argparse output to both sys.stdout and sys.stderr to self.stdout + with redirect_stdout(self.stdout): + with redirect_stderr(self.stdout): + func(new_arglist) + else: + # No special behavior needed, delegate to cmd base class do_help() + cmd.Cmd.do_help(self, funcname[3:]) else: # Show a menu of what commands help can be gotten for self._help_menu() @@ -1340,7 +1477,7 @@ class Cmd(cmd.Cmd): set_parser.add_argument('-l', '--long', action='store_true', help='describe function of parameter') set_parser.add_argument('settable', nargs='*', help='[param_name] [value]') - @with_argument_parser(set_parser) + @with_argparser(set_parser) def do_set(self, args): """Sets a settable parameter or shows current settings of parameters. @@ -1692,8 +1829,9 @@ Paths or arguments that contain spaces must be enclosed in quotes history_parser_group.add_argument('-r', '--run', action='store_true', help='run selected history items') history_parser_group.add_argument('-e', '--edit', action='store_true', help='edit and then run selected history items') - history_parser_group.add_argument('-o', '--output-file', metavar='FILE', help='output to file') - history_parser.add_argument('-s', '--script', action='store_true', help='script format; no separation lines') + history_parser_group.add_argument('-s', '--script', action='store_true', help='script format; no separation lines') + history_parser_group.add_argument('-o', '--output-file', metavar='FILE', help='output commands to a script file') + history_parser_group.add_argument('-t', '--transcript', help='output commands and results to a transcript file') _history_arg_help = """empty all history items a one history item by number a..b, a:b, a:, ..b items by indices (inclusive) @@ -1701,7 +1839,7 @@ a..b, a:b, a:, ..b items by indices (inclusive) /regex/ items matching regular expression""" history_parser.add_argument('arg', nargs='?', help=_history_arg_help) - @with_argument_parser(history_parser) + @with_argparser(history_parser) def do_history(self, args): """View, run, edit, and save previously entered commands.""" # If an argument was supplied, then retrieve partial contents of the history @@ -1722,7 +1860,8 @@ a..b, a:b, a:, ..b items by indices (inclusive) else: # If no arg given, then retrieve the entire history cowardly_refuse_to_run = True - history = self.history + # Get a copy of the history so it doesn't get mutated while we are using it + history = self.history[:] if args.run: if cowardly_refuse_to_run: @@ -1755,6 +1894,28 @@ a..b, a:b, a:, ..b items by indices (inclusive) self.pfeedback('{} command{} saved to {}'.format(len(history), plural, args.output_file)) except Exception as e: self.perror('Saving {!r} - {}'.format(args.output_file, e), traceback_war=False) + elif args.transcript: + # Make sure echo is on so commands print to standard out + saved_echo = self.echo + self.echo = True + + # Redirect stdout to the transcript file + saved_self_stdout = self.stdout + self.stdout = open(args.transcript, 'w') + + # Run all of the commands in the history with output redirected to transcript and echo on + self.runcmds_plus_hooks(history) + + # Restore stdout to its original state + self.stdout.close() + self.stdout = saved_self_stdout + + # Set echo back to its original state + self.echo = saved_echo + + plural = 's' if len(history) > 1 else '' + self.pfeedback('{} command{} and outputs saved to transcript file {!r}'.format(len(history), plural, + args.transcript)) else: # Display the history items retrieved for hi in history: diff --git a/docs/argument_processing.rst b/docs/argument_processing.rst index cc08e4e5..2a433bc7 100644 --- a/docs/argument_processing.rst +++ b/docs/argument_processing.rst @@ -13,7 +13,7 @@ Argument Processing 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 -These features are all provided by the ``@with_argument_parser`` decorator. +These features are all provided by the ``@with_argparser`` decorator. Using the argument parser decorator =================================== @@ -21,7 +21,7 @@ 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 input appropriately for the command. Then decorate the command method with -the ``@with_argument_parser`` decorator, passing the argument parser as the +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 of ``ArgumentParser.parse_args()``. @@ -33,7 +33,7 @@ Here's what it looks like:: argparser.add_argument('-r', '--repeat', type=int, help='output [n] times') argparser.add_argument('word', nargs='?', help='word to say') - @with_argument_parser(argparser) + @with_argparser(argparser) def do_speak(self, opts) """Repeats what you tell me to.""" arg = opts.word @@ -47,7 +47,7 @@ Here's what it looks like:: .. note:: - The ``@with_argument_parser`` decorator sets the ``prog`` variable in + The ``@with_argparser`` decorator sets the ``prog`` variable in the argument parser based on the name of the method it is decorating. This will override anything you specify in ``prog`` variable when creating the argument parser. @@ -57,14 +57,14 @@ Help Messages ============= By default, cmd2 uses the docstring of the command method when a user asks -for help on the command. When you use the ``@with_argument_parser`` +for help on the command. When you use the ``@with_argparser`` decorator, the docstring for the ``do_*`` method is used to set the description for the ``argparse.ArgumentParser`` is With this code:: argparser = argparse.ArgumentParser() argparser.add_argument('tag', help='tag') argparser.add_argument('content', nargs='+', help='content to surround with tag') - @with_argument_parser(argparser) + @with_argparser(argparser) def do_tag(self, args): """create a html tag""" self.stdout.write('<{0}>{1}</{0}>'.format(args.tag, ' '.join(args.content))) @@ -92,7 +92,7 @@ docstring on your method empty:: argparser = argparse.ArgumentParser(description='create an html tag') argparser.add_argument('tag', help='tag') argparser.add_argument('content', nargs='+', help='content to surround with tag') - @with_argument_parser(argparser) + @with_argparser(argparser) def do_tag(self, args): self.stdout.write('<{0}>{1}</{0}>'.format(args.tag, ' '.join(args.content))) self.stdout.write('\n') @@ -121,7 +121,7 @@ To add additional text to the end of the generated help message, use the ``epilo ) argparser.add_argument('tag', help='tag') argparser.add_argument('content', nargs='+', help='content to surround with tag') - @with_argument_parser(argparser) + @with_argparser(argparser) def do_tag(self, args): self.stdout.write('<{0}>{1}</{0}>'.format(args.tag, ' '.join(args.content))) self.stdout.write('\n') @@ -166,14 +166,14 @@ The default behavior of ``cmd2`` is to pass the user input directly to your Using the argument parser decorator and also receiving a a list of 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_list`` decorator. +decorate the command method with the ``@with_argparser_and_unknown_args`` decorator. Here's what it looks like:: dir_parser = argparse.ArgumentParser() dir_parser.add_argument('-l', '--long', action='store_true', help="display in long format with one item per line") - @with_argparser_and_list(dir_parser) + @with_argparser_and_unknown_args(dir_parser) def do_dir(self, args, unknown): """List contents of current directory.""" # No arguments for this command @@ -188,6 +188,15 @@ Here's what it looks like:: ... +Sub-commands +============ +Sub-commands 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. + +See the subcommands_ example to learn more about how to use sub-commands in your ``cmd2`` application. + +.. _subcommands: https://github.com/python-cmd2/cmd2/blob/master/examples/subcommands.py + Deprecated optparse support =========================== diff --git a/docs/conf.py b/docs/conf.py index fd3e9476..d4ef14bf 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -60,9 +60,9 @@ author = 'Catherine Devlin and Todd Leonhardt' # built documents. # # The short X.Y version. -version = '0.7' +version = '0.8' # The full version, including alpha/beta/rc tags. -release = '0.7.9' +release = '0.8.0' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/install.rst b/docs/install.rst index 19cbdd78..2c247a3e 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -128,6 +128,11 @@ If you wish to permanently uninstall ``cmd2``, this can also easily be done with pip uninstall cmd2 +Extra requirement for Python 3.4 and earlier +-------------------------------------------- +``cmd2`` requires the ``contextlib2`` module for Python 3.4 and earlier. This is used to temporarily redirect +stdout and stderr. + Extra requirement for Python 2.7 only ------------------------------------- If you want to be able to pipe the output of commands to a shell command on Python 2.7, then you will need one diff --git a/examples/arg_print.py b/examples/arg_print.py index 1b18cdf0..8b02bc51 100755 --- a/examples/arg_print.py +++ b/examples/arg_print.py @@ -14,7 +14,7 @@ import argparse import cmd2 import pyparsing -from cmd2 import with_argument_list, with_argument_parser, with_argparser_and_unknown_args +from cmd2 import with_argument_list, with_argparser, with_argparser_and_unknown_args class ArgumentAndOptionPrinter(cmd2.Cmd): @@ -47,7 +47,7 @@ class ArgumentAndOptionPrinter(cmd2.Cmd): oprint_parser.add_argument('-r', '--repeat', type=int, help='output [n] times') oprint_parser.add_argument('words', nargs='+', help='words to print') - @with_argument_parser(oprint_parser) + @with_argparser(oprint_parser) def do_oprint(self, args): """Print the options and argument list this options command was called with.""" print('oprint was called with the following\n\toptions: {!r}'.format(args)) diff --git a/examples/argparse_example.py b/examples/argparse_example.py index 9f6548de..fbb2b1dc 100755 --- a/examples/argparse_example.py +++ b/examples/argparse_example.py @@ -14,7 +14,7 @@ verifying that the output produced matches the transcript. import argparse import sys -from cmd2 import Cmd, options, with_argument_parser, with_argument_list +from cmd2 import Cmd, options, with_argparser, with_argument_list from optparse import make_option @@ -47,7 +47,7 @@ class CmdLineApp(Cmd): speak_parser.add_argument('-r', '--repeat', type=int, help='output [n] times') speak_parser.add_argument('words', nargs='+', help='words to say') - @with_argument_parser(speak_parser) + @with_argparser(speak_parser) def do_speak(self, args): """Repeats what you tell me to.""" words = [] @@ -68,7 +68,7 @@ class CmdLineApp(Cmd): tag_parser.add_argument('tag', help='tag') tag_parser.add_argument('content', nargs='+', help='content to surround with tag') - @with_argument_parser(tag_parser) + @with_argparser(tag_parser) def do_tag(self, args): """create a html tag""" self.poutput('<{0}>{1}</{0}>'.format(args.tag, ' '.join(args.content))) diff --git a/examples/example.py b/examples/example.py index 4ba0d29a..c66f0e60 100755 --- a/examples/example.py +++ b/examples/example.py @@ -14,7 +14,7 @@ the transcript. import random import argparse -from cmd2 import Cmd, with_argument_parser +from cmd2 import Cmd, with_argparser class CmdLineApp(Cmd): @@ -44,7 +44,7 @@ class CmdLineApp(Cmd): speak_parser.add_argument('-r', '--repeat', type=int, help='output [n] times') speak_parser.add_argument('words', nargs='+', help='words to say') - @with_argument_parser(speak_parser) + @with_argparser(speak_parser) def do_speak(self, args): """Repeats what you tell me to.""" words = [] @@ -66,7 +66,7 @@ class CmdLineApp(Cmd): mumble_parser.add_argument('-r', '--repeat', type=int, help='how many times to repeat') mumble_parser.add_argument('words', nargs='+', help='words to say') - @with_argument_parser(mumble_parser) + @with_argparser(mumble_parser) def do_mumble(self, args): """Mumbles what you tell me to.""" repetitions = args.repeat or 1 diff --git a/examples/pirate.py b/examples/pirate.py index dd9fd98c..cfe545d6 100755 --- a/examples/pirate.py +++ b/examples/pirate.py @@ -7,7 +7,7 @@ presented as part of her PyCon 2010 talk. It demonstrates many features of cmd2. """ import argparse -from cmd2 import Cmd, with_argument_parser +from cmd2 import Cmd, with_argparser class Pirate(Cmd): @@ -25,13 +25,13 @@ class Pirate(Cmd): """Initialize the base class as well as this one""" Cmd.__init__(self) # prompts and defaults - self.gold = 3 + self.gold = 0 self.initial_gold = self.gold self.prompt = 'arrr> ' def default(self, line): """This handles unknown commands.""" - print('What mean ye by "{0}"?'.format(line)) + self.poutput('What mean ye by "{0}"?'.format(line)) def precmd(self, line): """Runs just before a command line is parsed, but after the prompt is presented.""" @@ -41,10 +41,10 @@ class Pirate(Cmd): def postcmd(self, stop, line): """Runs right before a command is about to return.""" if self.gold != self.initial_gold: - print('Now we gots {0} doubloons' + self.poutput('Now we gots {0} doubloons' .format(self.gold)) if self.gold < 0: - print("Off to debtorrr's prison.") + self.poutput("Off to debtorrr's prison.") stop = True return stop @@ -61,30 +61,30 @@ class Pirate(Cmd): self.gold -= int(arg) except ValueError: if arg: - print('''What's "{0}"? I'll take rrrum.'''.format(arg)) + self.poutput('''What's "{0}"? I'll take rrrum.'''.format(arg)) self.gold -= 1 def do_quit(self, arg): """Quit the application gracefully.""" - print("Quiterrr!") + self.poutput("Quiterrr!") return True def do_sing(self, arg): """Sing a colorful song.""" - print(self.colorize(arg, self.songcolor)) + self.poutput(self.colorize(arg, self.songcolor)) yo_parser = argparse.ArgumentParser() yo_parser.add_argument('--ho', type=int, default=2, help="How often to chant 'ho'") yo_parser.add_argument('-c', '--commas', action='store_true', help='Intersperse commas') yo_parser.add_argument('beverage', help='beverage to drink with the chant') - @with_argument_parser(yo_parser) + @with_argparser(yo_parser) def do_yo(self, args): """Compose a yo-ho-ho type chant with flexible options.""" chant = ['yo'] + ['ho'] * args.ho separator = ', ' if args.commas else ' ' chant = separator.join(chant) - print('{0} and a bottle of {1}'.format(chant, args.beverage)) + self.poutput('{0} and a bottle of {1}'.format(chant, args.beverage)) if __name__ == '__main__': diff --git a/examples/subcommands.py b/examples/subcommands.py new file mode 100755 index 00000000..e77abc61 --- /dev/null +++ b/examples/subcommands.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python +# coding=utf-8 +"""A simple example demonstrating how to use Argparse to support sub-commands. + + +This example shows an easy way for a single command to have many subcommands, each of which takes different arguments +and provides separate contextual help. +""" +import argparse + +import cmd2 +from cmd2 import with_argparser + + +class SubcommandsExample(cmd2.Cmd): + """ Example cmd2 application where we a base command which has a couple subcommands.""" + + def __init__(self): + cmd2.Cmd.__init__(self) + + # sub-command functions for the base command + def base_foo(self, args): + """foo subcommand of base command""" + self.poutput(args.x * args.y) + + def base_bar(self, args): + """bar sucommand of base command""" + self.poutput('((%s))' % args.z) + + # create the top-level parser for the base command + base_parser = argparse.ArgumentParser(prog='base') + base_subparsers = base_parser.add_subparsers(title='subcommands', help='subcommand help') + + # create the parser for the "foo" sub-command + parser_foo = base_subparsers.add_parser('foo', help='foo help') + parser_foo.add_argument('-x', type=int, default=1, help='integer') + parser_foo.add_argument('y', type=float, help='float') + parser_foo.set_defaults(func=base_foo) + + # create the parser for the "bar" sub-command + parser_bar = base_subparsers.add_parser('bar', help='bar help') + parser_bar.add_argument('z', help='string') + parser_bar.set_defaults(func=base_bar) + + # Create a list of subcommand names, which is used to enable tab-completion of sub-commands + subcommands = ['foo', 'bar'] + + @with_argparser(base_parser, subcommands) + def do_base(self, args): + """Base command help""" + try: + # Call whatever sub-command function was selected + args.func(self, args) + except AttributeError: + # No sub-command was provided, so as called + self.do_help('base') + + +if __name__ == '__main__': + app = SubcommandsExample() + app.cmdloop() diff --git a/examples/exampleSession.txt b/examples/transcripts/exampleSession.txt index 840bee60..840bee60 100644 --- a/examples/exampleSession.txt +++ b/examples/transcripts/exampleSession.txt diff --git a/examples/transcripts/pirate.transcript b/examples/transcripts/pirate.transcript new file mode 100644 index 00000000..570f0cd7 --- /dev/null +++ b/examples/transcripts/pirate.transcript @@ -0,0 +1,10 @@ +arrr> loot +Now we gots 1 doubloons +arrr> loot +Now we gots 2 doubloons +arrr> loot +Now we gots 3 doubloons +arrr> drink 3 +Now we gots 0 doubloons +arrr> yo --ho 3 rum +yo ho ho ho and a bottle of rum diff --git a/examples/transcript_regex.txt b/examples/transcripts/transcript_regex.txt index 7d017dee..7d017dee 100644 --- a/examples/transcript_regex.txt +++ b/examples/transcripts/transcript_regex.txt @@ -6,7 +6,7 @@ Setuptools setup file, used to install or test 'cmd2' import sys from setuptools import setup -VERSION = '0.8.0a' +VERSION = '0.8.0' 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 @@ -62,9 +62,19 @@ Topic :: Software Development :: Libraries :: Python Modules """.splitlines()))) INSTALL_REQUIRES = ['pyparsing >= 2.0.1', 'pyperclip', 'six'] + +# Windows also requires pyreadline to ensure tab completion works if sys.platform.startswith('win'): INSTALL_REQUIRES += ['pyreadline'] +# Python 3.4 and earlier require contextlib2 for temporarily redirecting stderr and stdout +if sys.version_info < (3, 5): + INSTALL_REQUIRES += ['contextlib2'] + +# Python 2.7 also requires subprocess32 +if sys.version_info < (3, 0): + INSTALL_REQUIRES += ['subprocess32'] + # unittest.mock was added in Python 3.3. mock is a backport of unittest.mock to all versions of Python TESTS_REQUIRE = ['mock', 'pytest'] DOCS_REQUIRE = ['sphinx', 'sphinx_rtd_theme', 'pyparsing', 'pyperclip', 'six'] diff --git a/tests/conftest.py b/tests/conftest.py index 021af193..319e54fe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,7 +19,7 @@ edit help history load py pyscript quit set shell shortcuts """ # Help text for the history command -HELP_HISTORY = """usage: history [-h] [-r | -e | -o FILE] [-s] [arg] +HELP_HISTORY = """usage: history [-h] [-r | -e | -s | -o FILE | -t TRANSCRIPT] [arg] View, run, edit, and save previously entered commands. @@ -34,10 +34,11 @@ optional arguments: -h, --help show this help message and exit -r, --run run selected history items -e, --edit edit and then run selected history items - -o FILE, --output-file FILE - output to file -s, --script script format; no separation lines - + -o FILE, --output-file FILE + output commands to a script file + -t TRANSCRIPT, --transcript TRANSCRIPT + output commands and results to a transcript file """ # Output from the shortcuts command with default built-in shortcuts diff --git a/tests/test_argparse.py b/tests/test_argparse.py index 21e81603..d3646046 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -8,6 +8,7 @@ import pytest import cmd2 from conftest import run_cmd, StdOut + class ArgparseApp(cmd2.Cmd): def __init__(self): self.maxrepeats = 3 @@ -19,7 +20,7 @@ class ArgparseApp(cmd2.Cmd): say_parser.add_argument('-r', '--repeat', type=int, help='output [n] times') say_parser.add_argument('words', nargs='+', help='words to say') - @cmd2.with_argument_parser(say_parser) + @cmd2.with_argparser(say_parser) def do_say(self, args): """Repeat what you tell me to.""" words = [] @@ -40,7 +41,7 @@ class ArgparseApp(cmd2.Cmd): tag_parser.add_argument('tag', help='tag') tag_parser.add_argument('content', nargs='+', help='content to surround with tag') - @cmd2.with_argument_parser(tag_parser) + @cmd2.with_argparser(tag_parser) def do_tag(self, args): self.stdout.write('<{0}>{1}</{0}>'.format(args.tag, ' '.join(args.content))) self.stdout.write('\n') @@ -162,3 +163,88 @@ def test_arglist(argparse_app): def test_arglist_decorator_twice(argparse_app): out = run_cmd(argparse_app, 'arglisttwice "we should" get these') assert out[0] == 'we should get these' + + +class SubcommandApp(cmd2.Cmd): + """ Example cmd2 application where we a base command which has a couple subcommands.""" + + def __init__(self): + cmd2.Cmd.__init__(self) + + # sub-command functions for the base command + def base_foo(self, args): + """foo subcommand of base command""" + self.poutput(args.x * args.y) + + def base_bar(self, args): + """bar sucommand of base command""" + self.poutput('((%s))' % args.z) + + # create the top-level parser for the base command + base_parser = argparse.ArgumentParser(prog='base') + base_subparsers = base_parser.add_subparsers(title='subcommands', help='subcommand help') + + # create the parser for the "foo" sub-command + parser_foo = base_subparsers.add_parser('foo', help='foo help') + parser_foo.add_argument('-x', type=int, default=1, help='integer') + parser_foo.add_argument('y', type=float, help='float') + parser_foo.set_defaults(func=base_foo) + + # create the parser for the "bar" sub-command + parser_bar = base_subparsers.add_parser('bar', help='bar help') + parser_bar.add_argument('z', help='string') + parser_bar.set_defaults(func=base_bar) + + # Create a list of subcommand names, which is used to enable tab-completion of sub-commands + subcommands = ['foo', 'bar'] + + @cmd2.with_argparser_and_unknown_args(base_parser, subcommands) + def do_base(self, args, arglist): + """Base command help""" + try: + # Call whatever sub-command function was selected + args.func(self, args) + except AttributeError: + # No sub-command was provided, so as called + self.do_help('base') + +@pytest.fixture +def subcommand_app(): + app = SubcommandApp() + app.stdout = StdOut() + return app + + +def test_subcommand_foo(subcommand_app): + out = run_cmd(subcommand_app, 'base foo -x2 5.0') + assert out == ['10.0'] + + +def test_subcommand_bar(subcommand_app): + out = run_cmd(subcommand_app, 'base bar baz') + assert out == ['((baz))'] + +def test_subcommand_invalid(subcommand_app, capsys): + run_cmd(subcommand_app, 'base baz') + out, err = capsys.readouterr() + err = err.splitlines() + assert err[0].startswith('usage: base') + assert err[1].startswith("base: error: invalid choice: 'baz'") + +def test_subcommand_base_help(subcommand_app): + out = run_cmd(subcommand_app, 'help base') + assert out[0].startswith('usage: base') + assert out[1] == '' + assert out[2] == 'Base command help' + +def test_subcommand_help(subcommand_app): + out = run_cmd(subcommand_app, 'help base foo') + assert out[0].startswith('usage: base foo') + assert out[1] == '' + assert out[2] == 'positional arguments:' + + +def test_subcommand_invalid_help(subcommand_app): + out = run_cmd(subcommand_app, 'help base baz') + assert out[0].startswith('usage: base') + assert out[1].startswith("base: error: invalid choice: 'baz'") diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 30308dd7..186def65 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -25,7 +25,7 @@ from conftest import run_cmd, normalize, BASE_HELP, HELP_HISTORY, SHORTCUTS_TXT, def test_ver(): - assert cmd2.__version__ == '0.8.0a' + assert cmd2.__version__ == '0.8.0' def test_empty_statement(base_app): @@ -41,19 +41,24 @@ def test_base_help(base_app): def test_base_help_history(base_app): out = run_cmd(base_app, 'help history') - expected = normalize(HELP_HISTORY) - assert out == expected + assert out == normalize(HELP_HISTORY) def test_base_argparse_help(base_app, capsys): + # Verify that "set -h" gives the same output as "help set" and that it starts in a way that makes sense run_cmd(base_app, 'set -h') out, err = capsys.readouterr() - expected = run_cmd(base_app, 'help set') - assert normalize(base_app.do_set.__doc__ + str(err)) == expected + out1 = out.splitlines() + + out2 = run_cmd(base_app, 'help set') + + assert out1 == out2 + assert out1[0].startswith('usage: set') + assert out1[1] == '' + assert out1[2].startswith('Sets a settable parameter') def test_base_invalid_option(base_app, capsys): run_cmd(base_app, 'set -z') out, err = capsys.readouterr() - run_cmd(base_app, 'help set') expected = ['usage: set [-h] [-a] [-l] [settable [settable ...]]', 'set: error: unrecognized arguments: -z'] assert normalize(str(err)) == expected @@ -605,8 +610,7 @@ def test_input_redirection(base_app, request): # Verify that redirecting input ffom a file works out = run_cmd(base_app, 'help < {}'.format(filename)) - expected = normalize(HELP_HISTORY) - assert out == expected + assert out == normalize(HELP_HISTORY) def test_pipe_to_shell(base_app, capsys): diff --git a/tests/test_completion.py b/tests/test_completion.py index efc32986..70f77d0a 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -8,10 +8,13 @@ file system paths, and shell commands. Copyright 2017 Todd Leonhardt <todd.leonhardt@gmail.com> Released under MIT license, see LICENSE file """ +import argparse import os +import readline import sys import cmd2 +import mock import pytest @@ -35,6 +38,100 @@ def test_cmd2_command_completion_single_end(cmd2_app): # It is at end of line, so extra space is present assert cmd2_app.completenames(text, line, begidx, endidx) == ['help '] +def test_complete_command_single_end(cmd2_app): + text = 'he' + line = 'he' + state = 0 + endidx = len(line) + begidx = endidx - len(text) + + def get_line(): + return line + + def get_begidx(): + return begidx + + def get_endidx(): + return endidx + + with mock.patch.object(readline, 'get_line_buffer', get_line): + with mock.patch.object(readline, 'get_begidx', get_begidx): + with mock.patch.object(readline, 'get_endidx', get_endidx): + # Run the readline tab-completion function with readline mocks in place + completion = cmd2_app.complete(text, state) + assert completion == 'help ' + +def test_complete_command_invalid_state(cmd2_app): + text = 'he' + line = 'he' + state = 1 + endidx = len(line) + begidx = endidx - len(text) + + def get_line(): + return line + + def get_begidx(): + return begidx + + def get_endidx(): + return endidx + + with mock.patch.object(readline, 'get_line_buffer', get_line): + with mock.patch.object(readline, 'get_begidx', get_begidx): + with mock.patch.object(readline, 'get_endidx', get_endidx): + # Run the readline tab-completion function with readline mocks in place get None + completion = cmd2_app.complete(text, state) + assert completion is None + +def test_complete_empty_arg(cmd2_app): + text = '' + line = 'help ' + state = 0 + endidx = len(line) + begidx = endidx - len(text) + + def get_line(): + return line + + def get_begidx(): + return begidx + + def get_endidx(): + return endidx + + with mock.patch.object(readline, 'get_line_buffer', get_line): + with mock.patch.object(readline, 'get_begidx', get_begidx): + with mock.patch.object(readline, 'get_endidx', get_endidx): + # Run the readline tab-completion function with readline mocks in place + completion = cmd2_app.complete(text, state) + + assert completion == cmd2_app.complete_help(text, line, begidx, endidx)[0] + +def test_complete_bogus_command(cmd2_app): + text = '' + line = 'fizbuzz ' + state = 0 + endidx = len(line) + begidx = endidx - len(text) + + def get_line(): + return line + + def get_begidx(): + return begidx + + def get_endidx(): + return endidx + + with mock.patch.object(readline, 'get_line_buffer', get_line): + with mock.patch.object(readline, 'get_begidx', get_begidx): + with mock.patch.object(readline, 'get_endidx', get_endidx): + # Run the readline tab-completion function with readline mocks in place + completion = cmd2_app.complete(text, state) + + assert completion is None + def test_cmd2_command_completion_is_case_insensitive_by_default(cmd2_app): text = 'HE' line = 'HE' @@ -323,3 +420,123 @@ def test_parseline_expands_shortcuts(cmd2_app): assert command == 'shell' assert args == 'cat foobar.txt' assert line.replace('!', 'shell ') == out_line + + +class SubcommandsExample(cmd2.Cmd): + """ Example cmd2 application where we a base command which has a couple subcommands.""" + + def __init__(self): + cmd2.Cmd.__init__(self) + + # sub-command functions for the base command + def base_foo(self, args): + """foo subcommand of base command""" + self.poutput(args.x * args.y) + + def base_bar(self, args): + """bar sucommand of base command""" + self.poutput('((%s))' % args.z) + + # create the top-level parser for the base command + base_parser = argparse.ArgumentParser(prog='base') + base_subparsers = base_parser.add_subparsers(title='subcommands', help='subcommand help') + + # create the parser for the "foo" sub-command + parser_foo = base_subparsers.add_parser('foo', help='foo help') + parser_foo.add_argument('-x', type=int, default=1, help='integer') + parser_foo.add_argument('y', type=float, help='float') + parser_foo.set_defaults(func=base_foo) + + # create the parser for the "bar" sub-command + parser_bar = base_subparsers.add_parser('bar', help='bar help') + parser_bar.add_argument('z', help='string') + parser_bar.set_defaults(func=base_bar) + + # Create a list of subcommand names, which is used to enable tab-completion of sub-commands + subcommands = ['foo', 'bar'] + + @cmd2.with_argparser(base_parser, subcommands) + def do_base(self, args): + """Base command help""" + try: + # Call whatever sub-command function was selected + args.func(self, args) + except AttributeError: + # No sub-command was provided, so as called + self.do_help('base') + + +@pytest.fixture +def sc_app(): + app = SubcommandsExample() + return app + + +def test_cmd2_subcommand_completion_single_end(sc_app): + text = 'f' + line = 'base f' + endidx = len(line) + begidx = endidx - len(text) + + # It is at end of line, so extra space is present + assert sc_app.complete_subcommand(text, line, begidx, endidx) == ['foo '] + +def test_cmd2_subcommand_completion_single_mid(sc_app): + text = 'f' + line = 'base f' + endidx = len(line) - 1 + begidx = endidx - len(text) + + # It is at end of line, so extra space is present + assert sc_app.complete_subcommand(text, line, begidx, endidx) == ['foo'] + +def test_cmd2_subcommand_completion_multiple(sc_app): + text = '' + line = 'base ' + endidx = len(line) + begidx = endidx - len(text) + + # It is at end of line, so extra space is present + assert sc_app.complete_subcommand(text, line, begidx, endidx) == ['foo', 'bar'] + +def test_cmd2_subcommand_completion_nomatch(sc_app): + text = 'z' + line = 'base z' + endidx = len(line) + begidx = endidx - len(text) + + # It is at end of line, so extra space is present + assert sc_app.complete_subcommand(text, line, begidx, endidx) == [] + +def test_cmd2_subcommand_completion_after_subcommand(sc_app): + text = 'f' + line = 'base foo f' + endidx = len(line) + begidx = endidx - len(text) + + # It is at end of line, so extra space is present + assert sc_app.complete_subcommand(text, line, begidx, endidx) == [] + + +def test_complete_subcommand_single_end(sc_app): + text = 'f' + line = 'base f' + endidx = len(line) + begidx = endidx - len(text) + state = 0 + + def get_line(): + return line + + def get_begidx(): + return begidx + + def get_endidx(): + return endidx + + with mock.patch.object(readline, 'get_line_buffer', get_line): + with mock.patch.object(readline, 'get_begidx', get_begidx): + with mock.patch.object(readline, 'get_endidx', get_endidx): + # Run the readline tab-completion function with readline mocks in place + completion = sc_app.complete(text, state) + assert completion == 'foo ' |