diff options
-rw-r--r-- | CHANGELOG.md | 8 | ||||
-rw-r--r-- | CONTRIBUTING.md | 2 | ||||
-rwxr-xr-x | README.md | 3 | ||||
-rw-r--r-- | cmd2/cmd2.py | 15 | ||||
-rw-r--r-- | examples/scripts/save_help_text.py | 107 | ||||
-rw-r--r-- | tests/test_cmd2.py | 28 |
6 files changed, 155 insertions, 8 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index a7383541..559014e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,12 @@ -## 0.9.7 (TBD, 2018) +## 0.9.7 (January 08, 2019) +* Bug Fixes + * Fixed bug when user chooses a zero or negative index when calling ``Cmd.select()`` + * Restored behavior where ``cmd_echo`` always starts as False in a py script. This was broken in 0.9.5. * Enhancements * **cmdloop** now only attempts to register a custom signal handler for SIGINT if running in the main thread * commands run as a result of ``default_to_shell`` being **True** now run via ``do_shell()`` and are saved - to history. + to history. + * Added more tab completion to pyscript command. * Deletions (potentially breaking changes) * Deleted ``Cmd.colorize()`` and ``Cmd._colorcodes`` which were deprecated in 0.9.5 * Replaced ``dir_exe_only`` and ``dir_only`` flags in ``path_complete`` with optional ``path_filter`` function diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c3da497c..95185efe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -510,7 +510,7 @@ Since 0.9.2, the process of publishing a new release of `cmd2` to [PyPi](https:/ mostly automated. The manual steps are all git operations. Here's the checklist: 1. Make sure you're on the proper branch (almost always **master**) -1. Make sure all the unit tests pass wih `invoke pypi-test` or `py.test` +1. Make sure all the unit tests pass with `invoke pytest` or `py.test` 1. Make sure `CHANGELOG.md` describes the version and has the correct release date 1. Add a git tag representing the version number using ``invoke tag x.y.z`` * Where x, y, and z are all small non-negative integers @@ -132,6 +132,7 @@ Instructions for implementing each feature follow. argparser.add_argument('-p', '--piglatin', action='store_true', help='atinLay') argparser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE') argparser.add_argument('words', nargs='+', help='words to say') + @with_argparser(argparser) def do_speak(self, args): """Repeats what you tell me to.""" @@ -253,6 +254,7 @@ class CmdLineApp(cmd2.Cmd): speak_parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE') speak_parser.add_argument('-r', '--repeat', type=int, help='output [n] times') speak_parser.add_argument('words', nargs='+', help='words to say') + @cmd2.with_argparser(speak_parser) def do_speak(self, args): """Repeats what you tell me to.""" @@ -274,6 +276,7 @@ class CmdLineApp(cmd2.Cmd): mumble_parser = argparse.ArgumentParser() mumble_parser.add_argument('-r', '--repeat', type=int, help='how many times to repeat') mumble_parser.add_argument('words', nargs='+', help='words to say') + @cmd2.with_argparser(mumble_parser) def do_mumble(self, args): """Mumbles what you tell me to.""" diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 0184d889..c10974f1 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -2789,6 +2789,8 @@ class Cmd(cmd.Cmd): try: choice = int(response) + if choice < 1: + raise IndexError result = fulloptions[choice - 1][0] break except (ValueError, IndexError): @@ -2975,6 +2977,10 @@ class Cmd(cmd.Cmd): :param filename: filename of *.py script file to run """ expanded_filename = os.path.expanduser(filename) + + # cmd_echo defaults to False for scripts. The user can always toggle this value in their script. + bridge.cmd_echo = False + try: with open(expanded_filename) as f: interp.runcode(f.read()) @@ -2996,12 +3002,14 @@ class Cmd(cmd.Cmd): interp = InteractiveConsole(locals=localvars) interp.runcode('import sys, os;sys.path.insert(0, os.getcwd())') + # Check if the user is running a Python statement on the command line if args.command: full_command = args.command if args.remainder: full_command += ' ' + ' '.join(args.remainder) - # If running at the CLI, print the output of the command + # Set cmd_echo to True so PyscriptBridge statements like: py app('help') + # run at the command line will print their output. bridge.cmd_echo = True interp.runcode(full_command) @@ -3119,8 +3127,9 @@ class Cmd(cmd.Cmd): pyscript_parser = ACArgumentParser() setattr(pyscript_parser.add_argument('script_path', help='path to the script file'), ACTION_ARG_CHOICES, ('path_complete',)) - pyscript_parser.add_argument('script_arguments', nargs=argparse.REMAINDER, - help='arguments to pass to script') + setattr(pyscript_parser.add_argument('script_arguments', nargs=argparse.REMAINDER, + help='arguments to pass to script'), + ACTION_ARG_CHOICES, ('path_complete',)) @with_argparser(pyscript_parser) def do_pyscript(self, args: argparse.Namespace) -> None: diff --git a/examples/scripts/save_help_text.py b/examples/scripts/save_help_text.py new file mode 100644 index 00000000..73434a31 --- /dev/null +++ b/examples/scripts/save_help_text.py @@ -0,0 +1,107 @@ +# coding=utf-8 +# flake8: noqa F821 +""" +A cmd2 script that saves the help text for every command, sub-command, and topic to a file. +This is meant to be run within a cmd2 session using pyscript. +""" + +import argparse +import os +import sys +from typing import List, TextIO + +ASTERISKS = "********************************************************" + + +def get_sub_commands(parser: argparse.ArgumentParser) -> List[str]: + """Get a list of sub-commands for an ArgumentParser""" + sub_cmds = [] + + # Check if this is parser has sub-commands + if parser is not None and parser._subparsers is not None: + + # Find the _SubParsersAction for the sub-commands of this parser + for action in parser._subparsers._actions: + if isinstance(action, argparse._SubParsersAction): + for sub_cmd, sub_cmd_parser in action.choices.items(): + sub_cmds.append(sub_cmd) + + # Look for nested sub-commands + for nested_sub_cmd in get_sub_commands(sub_cmd_parser): + sub_cmds.append('{} {}'.format(sub_cmd, nested_sub_cmd)) + + break + + sub_cmds.sort() + return sub_cmds + + +def add_help_to_file(item: str, outfile: TextIO, is_command: bool) -> None: + """ + Write help text for commands and topics to the output file + :param item: what is having its help text saved + :param outfile: file being written to + :param is_command: tells if the item is a command and not just a help topic + """ + if is_command: + label = "COMMAND" + else: + label = "TOPIC" + + header = '{}\n{}: {}\n{}\n'.format(ASTERISKS, label, item, ASTERISKS) + outfile.write(header) + + result = app('help {}'.format(item)) + outfile.write(result.stdout) + + +def main() -> None: + """Main function of this script""" + + # Make sure we have access to self + if 'self' not in globals(): + print("Run 'set locals_in_py true' and then rerun this script") + return + + # Make sure the user passed in an output file + if len(sys.argv) != 2: + print("Usage: {} <output_file>".format(os.path.basename(sys.argv[0]))) + return + + # Open the output file + outfile_path = os.path.expanduser(sys.argv[1]) + try: + outfile = open(outfile_path, 'w') + except OSError as e: + print("Error opening {} because: {}".format(outfile_path, e)) + return + + # Write the help summary + header = '{0}\nSUMMARY\n{0}\n'.format(ASTERISKS) + outfile.write(header) + + result = app('help -v') + outfile.write(result.stdout) + + # Get a list of all commands and help topics and then filter out duplicates + all_commands = set(self.get_all_commands()) + all_topics = set(self.get_help_topics()) + to_save = list(all_commands | all_topics) + to_save.sort() + + for item in to_save: + is_command = item in all_commands + add_help_to_file(item, outfile, is_command) + + if is_command: + # Add any sub-commands + for subcmd in get_sub_commands(getattr(self.cmd_func(item), 'argparser', None)): + full_cmd = '{} {}'.format(item, subcmd) + add_help_to_file(full_cmd, outfile, is_command) + + outfile.close() + print("Output written to {}".format(outfile_path)) + + +# Run main function +main() diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 008f2cc6..350991fa 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1333,11 +1333,11 @@ def test_select_options(select_app): # And verify the expected output to stdout assert out == expected -def test_select_invalid_option(select_app): +def test_select_invalid_option_too_big(select_app): # Mock out the input call so we don't actually wait for a user's response on stdin m = mock.MagicMock(name='input') # If side_effect is an iterable then each call to the mock will return the next value from the iterable. - m.side_effect = ['3', '1'] # First pass and invalid selection, then pass a valid one + m.side_effect = ['3', '1'] # First pass an invalid selection, then pass a valid one builtins.input = m food = 'fish' @@ -1357,6 +1357,30 @@ def test_select_invalid_option(select_app): # And verify the expected output to stdout assert out == expected +def test_select_invalid_option_too_small(select_app): + # Mock out the input call so we don't actually wait for a user's response on stdin + m = mock.MagicMock(name='input') + # If side_effect is an iterable then each call to the mock will return the next value from the iterable. + m.side_effect = ['0', '1'] # First pass an invalid selection, then pass a valid one + builtins.input = m + + food = 'fish' + out = run_cmd(select_app, "eat {}".format(food)) + expected = normalize(""" + 1. sweet + 2. salty +'0' isn't a valid choice. Pick a number between 1 and 2: +{} with sweet sauce, yum! +""".format(food)) + + # Make sure our mock was called exactly twice with the expected arguments + arg = 'Sauce? ' + calls = [mock.call(arg), mock.call(arg)] + m.assert_has_calls(calls) + + # And verify the expected output to stdout + assert out == expected + def test_select_list_of_strings(select_app): # Mock out the input call so we don't actually wait for a user's response on stdin m = mock.MagicMock(name='input', return_value='2') |