diff options
author | Todd Leonhardt <todd.leonhardt@gmail.com> | 2018-09-28 21:26:24 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-09-28 21:26:24 -0400 |
commit | 61d5703cd3586b3460669a6260cf903c9863b240 (patch) | |
tree | 5b7f4893f7edfa60435946f1027b07933fe1b3cf | |
parent | 87fdda149ade5c82d853334c87db0a2d11445594 (diff) | |
parent | 46bd94acbd10eced821827555a0ffd49a2f9cd92 (diff) | |
download | cmd2-git-61d5703cd3586b3460669a6260cf903c9863b240.tar.gz |
Merge pull request #553 from python-cmd2/argparse_conversion
Argparse conversion
-rw-r--r-- | CHANGELOG.md | 2 | ||||
-rw-r--r-- | cmd2/cmd2.py | 389 | ||||
-rw-r--r-- | docs/freefeatures.rst | 10 | ||||
-rw-r--r-- | tests/conftest.py | 18 | ||||
-rw-r--r-- | tests/test_argparse.py | 2 | ||||
-rw-r--r-- | tests/test_cmd2.py | 61 | ||||
-rw-r--r-- | tests/test_completion.py | 92 | ||||
-rw-r--r-- | tests/test_pyscript.py | 10 |
8 files changed, 324 insertions, 260 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index bad61734..d2d0adec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ * Always - output methods **never** strip ANSI escape sequences, regardless of the output destination * Never - output methods strip all ANSI escape sequences * Added ``macro`` command to create macros, which are similar to aliases, but can take arguments when called - * ``alias`` is now an argparse command with subcommands to create, list, and delete aliases + * All cmd2 command functions have been converted to use argparse. * Deprecations * Deprecated the built-in ``cmd2`` support for colors including ``Cmd.colorize()`` and ``Cmd._colorcodes`` * Deletions (potentially breaking changes) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index d029aa6c..dec0a04d 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -122,6 +122,10 @@ except ImportError: # pragma: no cover HELP_CATEGORY = 'help_category' HELP_SUMMARY = 'help_summary' +INTERNAL_COMMAND_EPILOG = ("Notes:\n" + " This command is for internal use and is not intended to be called from the\n" + " command line.") + # All command functions start with this COMMAND_PREFIX = 'do_' @@ -386,6 +390,10 @@ class Cmd(cmd.Cmd): # Call super class constructor super().__init__(completekey=completekey, stdin=stdin, stdout=stdout) + # Get rid of cmd's complete_help() functions so AutoCompleter will complete our help command + if getattr(cmd.Cmd, 'complete_help', None) is not None: + delattr(cmd.Cmd, 'complete_help') + # Commands to exclude from the help menu and tab completion self.hidden_commands = ['eof', 'eos', '_relative_load'] @@ -1632,46 +1640,6 @@ class Cmd(cmd.Cmd): return [name[5:] for name in self.get_names() if name.startswith('help_') and callable(getattr(self, name))] - def complete_help(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: - """ - Override of parent class method to handle tab completing subcommands and not showing hidden commands - Returns a list of possible tab completions - """ - - # The command is the token at index 1 in the command line - cmd_index = 1 - - # The subcommand is the token at index 2 in the command line - subcmd_index = 2 - - # Get all tokens through the one being completed - tokens, _ = self.tokens_for_completion(line, begidx, endidx) - if not tokens: - return [] - - matches = [] - - # Get the index of the token being completed - index = len(tokens) - 1 - - # Check if we are completing a command or help topic - if index == cmd_index: - - # Complete token against topics and visible commands - topics = set(self.get_help_topics()) - visible_commands = set(self.get_visible_commands()) - strs_to_match = list(topics | visible_commands) - matches = self.basic_complete(text, line, begidx, endidx, strs_to_match) - - # check if the command uses argparser - elif index >= subcmd_index: - func = self.cmd_func(tokens[cmd_index]) - if func and hasattr(func, 'argparser'): - completer = AutoCompleter(getattr(func, 'argparser'), cmd2_app=self) - matches = completer.complete_command_help(tokens[1:], text, line, begidx, endidx) - - return matches - # noinspection PyUnusedLocal def sigint_handler(self, signum: int, frame) -> None: """Signal handler for SIGINTs which typically come from Ctrl-C events. @@ -2275,7 +2243,7 @@ class Cmd(cmd.Cmd): self.aliases.clear() self.poutput("All aliases deleted") elif not args.name: - self.do_help(['alias', 'delete']) + self.do_help('alias delete') else: # Get rid of duplicates and strip quotes since the argparse decorator for do_alias() preserves them aliases_to_delete = [utils.strip_quotes(cur_name) for cur_name in utils.remove_duplicates(args.name)] @@ -2337,7 +2305,7 @@ class Cmd(cmd.Cmd): setattr(alias_create_parser.add_argument('command', help='what the alias resolves to'), ACTION_ARG_CHOICES, get_commands_aliases_and_macros_for_completion) setattr(alias_create_parser.add_argument('command_args', nargs=argparse.REMAINDER, - help='arguments being passed to command'), + help='arguments to pass to command'), ACTION_ARG_CHOICES, ('path_complete',)) alias_create_parser.set_defaults(func=alias_create) @@ -2374,7 +2342,7 @@ class Cmd(cmd.Cmd): func(self, args) else: # No subcommand was provided, so call help - self.do_help(['alias']) + self.do_help('alias') # ----- Macro subcommand functions ----- @@ -2463,7 +2431,7 @@ class Cmd(cmd.Cmd): self.macros.clear() self.poutput("All macros deleted") elif not args.name: - self.do_help(['macro', 'delete']) + self.do_help('macro delete') else: # Get rid of duplicates and strip quotes since the argparse decorator for do_macro() preserves them macros_to_delete = [utils.strip_quotes(cur_name) for cur_name in utils.remove_duplicates(args.name)] @@ -2549,7 +2517,7 @@ class Cmd(cmd.Cmd): setattr(macro_create_parser.add_argument('command', help='what the macro resolves to'), ACTION_ARG_CHOICES, get_commands_aliases_and_macros_for_completion) setattr(macro_create_parser.add_argument('command_args', nargs=argparse.REMAINDER, - help='arguments being passed to command'), + help='arguments to pass to command'), ACTION_ARG_CHOICES, ('path_complete',)) macro_create_parser.set_defaults(func=macro_create) @@ -2585,23 +2553,77 @@ class Cmd(cmd.Cmd): func(self, args) else: # No subcommand was provided, so call help - self.do_help(['macro']) - - @with_argument_list - def do_help(self, arglist: List[str]) -> None: - """ List available commands with "help" or detailed help with "help cmd" """ - if not arglist or (len(arglist) == 1 and arglist[0] in ('--verbose', '-v')): - verbose = len(arglist) == 1 and arglist[0] in ('--verbose', '-v') - self._help_menu(verbose) + self.do_help('macro') + + def complete_help_command(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + """Completes the command argument of help""" + + # Complete token against topics and visible commands + topics = set(self.get_help_topics()) + visible_commands = set(self.get_visible_commands()) + strs_to_match = list(topics | visible_commands) + return self.basic_complete(text, line, begidx, endidx, strs_to_match) + + def complete_help_subcommand(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + """Completes the subcommand argument of help""" + + # Get all tokens through the one being completed + tokens, _ = self.tokens_for_completion(line, begidx, endidx) + + if not tokens: + return [] + + # Must have at least 3 args for 'help command subcommand' + if len(tokens) < 3: + return [] + + # Find where the command is by skipping past any flags + cmd_index = 1 + for cur_token in tokens[cmd_index:]: + if not cur_token.startswith('-'): + break + cmd_index += 1 + + if cmd_index >= len(tokens): + return [] + + command = tokens[cmd_index] + matches = [] + + # Check if this is a command with an argparse function + func = self.cmd_func(command) + if func and hasattr(func, 'argparser'): + completer = AutoCompleter(getattr(func, 'argparser'), cmd2_app=self) + matches = completer.complete_command_help(tokens[cmd_index:], text, line, begidx, endidx) + + return matches + + help_parser = ACArgumentParser() + + setattr(help_parser.add_argument('command', help="command to retrieve help for", nargs="?"), + ACTION_ARG_CHOICES, ('complete_help_command',)) + setattr(help_parser.add_argument('subcommand', help="subcommand to retrieve help for", + nargs=argparse.REMAINDER), + ACTION_ARG_CHOICES, ('complete_help_subcommand',)) + help_parser.add_argument('-v', '--verbose', action='store_true', + help="print a list of all commands with descriptions of each") + + @with_argparser(help_parser) + def do_help(self, args: argparse.Namespace) -> None: + """List available commands or provide detailed help for a specific command""" + if not args.command or args.verbose: + self._help_menu(args.verbose) + else: # Getting help for a specific command - func = self.cmd_func(arglist[0]) + func = self.cmd_func(args.command) if func and hasattr(func, 'argparser'): completer = AutoCompleter(getattr(func, 'argparser'), cmd2_app=self) - self.poutput(completer.format_help(arglist)) + tokens = [args.command] + args.subcommand + self.poutput(completer.format_help(tokens)) else: # No special behavior needed, delegate to cmd base class do_help() - super().do_help(arglist[0]) + super().do_help(args.command) def _help_menu(self, verbose: bool=False) -> None: """Show a list of commands which help can be displayed for. @@ -2721,18 +2743,21 @@ class Cmd(cmd.Cmd): command = '' self.stdout.write("\n") - def do_shortcuts(self, _: str) -> None: - """Lists shortcuts available""" + @with_argparser(ACArgumentParser()) + def do_shortcuts(self, _: argparse.Namespace) -> None: + """List available shortcuts""" result = "\n".join('%s: %s' % (sc[0], sc[1]) for sc in sorted(self.shortcuts)) self.poutput("Shortcuts for other commands:\n{}\n".format(result)) - def do_eof(self, _: str) -> bool: + @with_argparser(ACArgumentParser(epilog=INTERNAL_COMMAND_EPILOG)) + def do_eof(self, _: argparse.Namespace) -> bool: """Called when <Ctrl>-D is pressed""" # End of script should not exit app, but <Ctrl>-D should. return self._STOP_AND_EXIT - def do_quit(self, _: str) -> bool: - """Exits this application""" + @with_argparser(ACArgumentParser()) + def do_quit(self, _: argparse.Namespace) -> bool: + """Exit this application""" self._should_quit = True return self._STOP_AND_EXIT @@ -2818,7 +2843,7 @@ class Cmd(cmd.Cmd): else: raise LookupError("Parameter '{}' not supported (type 'set' for list of parameters).".format(param)) - set_description = ("Sets a settable parameter or shows current settings of parameters.\n" + set_description = ("Set a settable parameter or show current settings of parameters\n" "\n" "Accepts abbreviated parameter names so long as there is no ambiguity.\n" "Call without arguments for a list of settable parameters with their values.") @@ -2832,7 +2857,7 @@ class Cmd(cmd.Cmd): @with_argparser(set_parser) def do_set(self, args: argparse.Namespace) -> None: - """Sets a settable parameter or shows current settings of parameters""" + """Set a settable parameter or show current settings of parameters""" # Check if param was passed in if not args.param: @@ -2865,14 +2890,20 @@ class Cmd(cmd.Cmd): if onchange_hook is not None: onchange_hook(old=current_value, new=value) - def do_shell(self, statement: Statement) -> None: - """Execute a command as if at the OS prompt + shell_parser = ACArgumentParser() + setattr(shell_parser.add_argument('command', help='the command to run'), + ACTION_ARG_CHOICES, ('shell_cmd_complete',)) + setattr(shell_parser.add_argument('command_args', nargs=argparse.REMAINDER, + help='arguments to pass to command'), + ACTION_ARG_CHOICES, ('path_complete',)) - Usage: shell <command> [arguments]""" + @with_argparser(shell_parser, preserve_quotes=True) + def do_shell(self, args: argparse.Namespace) -> None: + """Execute a command as if at the OS prompt""" import subprocess - # Get list of arguments to shell with quotes preserved - tokens = statement.arg_list + # Create a list of arguments to shell + tokens = [args.command] + args.command_args # Support expanding ~ in quoted paths for index, _ in enumerate(tokens): @@ -2893,18 +2924,6 @@ class Cmd(cmd.Cmd): proc = subprocess.Popen(expanded_command, stdout=self.stdout, shell=True) proc.communicate() - def complete_shell(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: - """Handles tab completion of executable commands and local file system paths for the shell command - - :param text: the string prefix we are attempting to match (all returned matches must begin with it) - :param line: the current input line with leading whitespace removed - :param begidx: the beginning index of the prefix text - :param endidx: the ending index of the prefix text - :return: a list of possible tab completions - """ - index_dict = {1: self.shell_cmd_complete} - return self.index_based_complete(text, line, begidx, endidx, index_dict, self.path_complete) - @staticmethod def _reset_py_display() -> None: """ @@ -2929,16 +2948,13 @@ class Cmd(cmd.Cmd): sys.displayhook = sys.__displayhook__ sys.excepthook = sys.__excepthook__ - def do_py(self, arg: str) -> bool: - """ - Invoke python command, shell, or script + py_parser = ACArgumentParser() + py_parser.add_argument('command', help="command to run", nargs='?') + py_parser.add_argument('remainder', help="remainder of command", nargs=argparse.REMAINDER) - py <command>: Executes a Python command. - py: Enters interactive Python mode. - End with ``Ctrl-D`` (Unix) / ``Ctrl-Z`` (Windows), ``quit()``, '`exit()``. - Non-python commands can be issued with ``pyscript_name("your command")``. - Run python code from external script files with ``run("script.py")`` - """ + @with_argparser(py_parser) + def do_py(self, args: argparse.Namespace) -> bool: + """Invoke Python command or shell""" from .pyscript_bridge import PyscriptBridge, CommandResult if self._in_py: err = "Recursively entering interactive Python consoles is not allowed." @@ -2949,19 +2965,18 @@ class Cmd(cmd.Cmd): # noinspection PyBroadException try: - arg = arg.strip() - # Support the run command even if called prior to invoking an interactive interpreter def run(filename: str): """Run a Python script file in the interactive console. :param filename: filename of *.py script file to run """ + expanded_filename = os.path.expanduser(filename) try: - with open(filename) as f: + with open(expanded_filename) as f: interp.runcode(f.read()) except OSError as ex: - error_msg = "Error opening script file '{}': {}".format(filename, ex) + error_msg = "Error opening script file '{}': {}".format(expanded_filename, ex) self.perror(error_msg, traceback_war=False) bridge = PyscriptBridge(self) @@ -2976,8 +2991,12 @@ class Cmd(cmd.Cmd): interp = InteractiveConsole(locals=localvars) interp.runcode('import sys, os;sys.path.insert(0, os.getcwd())') - if arg: - interp.runcode(arg) + if args.command: + full_command = utils.quote_string_if_needed(args.command) + for cur_token in args.remainder: + full_command += ' ' + utils.quote_string_if_needed(cur_token) + + interp.runcode(full_command) # If there are no args, then we will open an interactive Python console else: @@ -3042,11 +3061,14 @@ class Cmd(cmd.Cmd): sys.stdin = self.stdin cprt = 'Type "help", "copyright", "credits" or "license" for more information.' - docstr = self.do_py.__doc__.replace('pyscript_name', self.pyscript_name) + instructions = ('End with `Ctrl-D` (Unix) / `Ctrl-Z` (Windows), `quit()`, `exit()`.\n' + 'Non-Python commands can be issued with: {}("your command")\n' + 'Run Python code from external script files with: run("script.py")' + .format(self.pyscript_name)) try: - interp.interact(banner="Python {} on {}\n{}\n({})\n{}". - format(sys.version, sys.platform, cprt, self.__class__.__name__, docstr)) + interp.interact(banner="Python {} on {}\n{}\n\n{}\n". + format(sys.version, sys.platform, cprt, instructions)) except EmbeddedConsoleExit: pass @@ -3087,30 +3109,22 @@ class Cmd(cmd.Cmd): self._in_py = False return self._should_quit - @with_argument_list - def do_pyscript(self, arglist: List[str]) -> None: - """\nRuns a python script file inside the console - - Usage: pyscript <script_path> [script_arguments] - -Console commands can be executed inside this script with cmd("your command") -However, you cannot run nested "py" or "pyscript" commands from within this script -Paths or arguments that contain spaces must be enclosed in quotes -""" - if not arglist: - self.perror("pyscript command requires at least 1 argument ...", traceback_war=False) - self.do_help(['pyscript']) - return + 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') - # Get the absolute path of the script - script_path = os.path.expanduser(arglist[0]) + @with_argparser(pyscript_parser) + def do_pyscript(self, args: argparse.Namespace) -> None: + """Run a Python script file inside the console""" + script_path = os.path.expanduser(args.script_path) # Save current command line arguments orig_args = sys.argv # Overwrite sys.argv to allow the script to take command line arguments - sys.argv = [script_path] - sys.argv.extend(arglist[1:]) + sys.argv = [script_path] + args.script_arguments # Run the script - use repr formatting to escape things which need to be escaped to prevent issues on Windows self.do_py("run({!r})".format(script_path)) @@ -3118,33 +3132,24 @@ Paths or arguments that contain spaces must be enclosed in quotes # Restore command line arguments to original state sys.argv = orig_args - def complete_pyscript(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: - """Enable tab-completion for pyscript command.""" - index_dict = {1: self.path_complete} - return self.index_based_complete(text, line, begidx, endidx, index_dict) - # Only include the do_ipy() method if IPython is available on the system - if ipython_available: - # noinspection PyMethodMayBeStatic,PyUnusedLocal - def do_ipy(self, arg: str) -> None: - """Enters an interactive IPython shell. - - Run python code from external files with ``run filename.py`` - End with ``Ctrl-D`` (Unix) / ``Ctrl-Z`` (Windows), ``quit()``, '`exit()``. - """ + if ipython_available: # pragma: no cover + @with_argparser(ACArgumentParser()) + def do_ipy(self, _: argparse.Namespace) -> None: + """Enter an interactive IPython shell""" from .pyscript_bridge import PyscriptBridge bridge = PyscriptBridge(self) + banner = ('Entering an embedded IPython shell. Type quit or <Ctrl>-d to exit.\n' + 'Run Python code from external files with: run filename.py\n') + exit_msg = 'Leaving IPython, back to {}'.format(sys.argv[0]) + if self.locals_in_py: def load_ipy(self, app): - banner = 'Entering an embedded IPython shell. Type quit() or <Ctrl>-d to exit ...' - exit_msg = 'Leaving IPython, back to {}'.format(sys.argv[0]) embed(banner1=banner, exit_msg=exit_msg) load_ipy(self, bridge) else: def load_ipy(app): - banner = 'Entering an embedded IPython shell. Type quit() or <Ctrl>-d to exit ...' - exit_msg = 'Leaving IPython, back to {}'.format(sys.argv[0]) embed(banner1=banner, exit_msg=exit_msg) load_ipy(bridge) @@ -3153,10 +3158,10 @@ 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('-s', '--script', action='store_true', help='script format; no separation lines') + history_parser_group.add_argument('-s', '--script', action='store_true', help='output commands in script format') 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_parser_group.add_argument('-c', '--clear', action="store_true", help='clears all history') + history_parser_group.add_argument('-c', '--clear', action="store_true", help='clear all history') _history_arg_help = """empty all history items a one history item by number a..b, a:b, a:, ..b items by indices (inclusive) @@ -3308,29 +3313,28 @@ a..b, a:b, a:, ..b items by indices (inclusive) msg = '{} {} saved to transcript file {!r}' self.pfeedback(msg.format(len(history), plural, transcript_file)) - @with_argument_list - def do_edit(self, arglist: List[str]) -> None: - """Edit a file in a text editor + edit_description = ("Edit a file in a text editor\n" + "\n" + "The editor used is determined by a settable parameter. To set it:\n" + "\n" + " set editor (program-name)") -Usage: edit [file_path] - Where: - * file_path - path to a file to open in editor + edit_parser = ACArgumentParser(description=edit_description) + setattr(edit_parser.add_argument('file_path', help="path to a file to open in editor", nargs="?"), + ACTION_ARG_CHOICES, ('path_complete',)) -The editor used is determined by the ``editor`` settable parameter. -"set editor (program-name)" to change or set the EDITOR environment variable. -""" + @with_argparser(edit_parser) + def do_edit(self, args: argparse.Namespace) -> None: + """Edit a file in a text editor""" if not self.editor: raise EnvironmentError("Please use 'set editor' to specify your text editing program of choice.") - filename = arglist[0] if arglist else '' - if filename: - os.system('"{}" "{}"'.format(self.editor, filename)) - else: - os.system('"{}"'.format(self.editor)) - def complete_edit(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: - """Enable tab-completion for edit command.""" - index_dict = {1: self.path_complete} - return self.index_based_complete(text, line, begidx, endidx, index_dict) + editor = utils.quote_string_if_needed(self.editor) + if args.file_path: + expanded_path = utils.quote_string_if_needed(os.path.expanduser(args.file_path)) + os.system('{} {}'.format(editor, expanded_path)) + else: + os.system('{}'.format(editor)) @property def _current_script_dir(self) -> Optional[str]: @@ -3340,54 +3344,25 @@ The editor used is determined by the ``editor`` settable parameter. else: return None - @with_argument_list - def do__relative_load(self, arglist: List[str]) -> None: - """Runs commands in script file that is encoded as either ASCII or UTF-8 text - - Usage: _relative_load <file_path> - - optional argument: - file_path a file path pointing to a script - -Script should contain one command per line, just like command would be typed in console. - -If this is called from within an already-running script, the filename will be interpreted -relative to the already-running script's directory. - -NOTE: This command is intended to only be used within text file scripts. - """ - # If arg is None or arg is an empty string this is an error - if not arglist: - self.perror('_relative_load command requires a file path:', traceback_war=False) - return - - file_path = arglist[0].strip() - # NOTE: Relative path is an absolute path, it is just relative to the current script directory - relative_path = os.path.join(self._current_script_dir or '', file_path) - self.do_load([relative_path]) - - def do_eos(self, _: str) -> None: - """Handles cleanup when a script has finished executing""" + @with_argparser(ACArgumentParser(epilog=INTERNAL_COMMAND_EPILOG)) + def do_eos(self, _: argparse.Namespace) -> None: + """Handle cleanup when a script has finished executing""" if self._script_dir: self._script_dir.pop() - @with_argument_list - def do_load(self, arglist: List[str]) -> None: - """Runs commands in script file that is encoded as either ASCII or UTF-8 text - - Usage: load <file_path> + load_description = ("Run commands in script file that is encoded as either ASCII or UTF-8 text\n" + "\n" + "Script should contain one command per line, just like the command would be\n" + "typed in the console.") - * file_path - a file path pointing to a script - -Script should contain one command per line, just like command would be typed in console. - """ - # If arg is None or arg is an empty string this is an error - if not arglist: - self.perror('load command requires a file path', traceback_war=False) - return + load_parser = ACArgumentParser(description=load_description) + setattr(load_parser.add_argument('script_path', help="path to the script file"), + ACTION_ARG_CHOICES, ('path_complete',)) - file_path = arglist[0].strip() - expanded_path = os.path.abspath(os.path.expanduser(file_path)) + @with_argparser(load_parser) + def do_load(self, args: argparse.Namespace) -> None: + """Run commands in script file that is encoded as either ASCII or UTF-8 text""" + expanded_path = os.path.abspath(os.path.expanduser(args.script_path)) # Make sure the path exists and we can access it if not os.path.exists(expanded_path): @@ -3421,10 +3396,24 @@ Script should contain one command per line, just like command would be typed in self._script_dir.append(os.path.dirname(expanded_path)) - def complete_load(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: - """Enable tab-completion for load command.""" - index_dict = {1: self.path_complete} - return self.index_based_complete(text, line, begidx, endidx, index_dict) + relative_load_description = load_description + relative_load_description += ("\n\n" + "If this is called from within an already-running script, the filename will be\n" + "interpreted relative to the already-running script's directory.") + + relative_load_epilog = ("Notes:\n" + " This command is intended to only be used within text file scripts.") + + relative_load_parser = ACArgumentParser(description=relative_load_description, epilog=relative_load_epilog) + relative_load_parser.add_argument('file_path', help='a file path pointing to a script') + + @with_argparser(relative_load_parser) + def do__relative_load(self, args: argparse.Namespace) -> None: + """""" + file_path = args.file_path + # NOTE: Relative path is an absolute path, it is just relative to the current script directory + relative_path = os.path.join(self._current_script_dir or '', file_path) + self.do_load(relative_path) def run_transcript_tests(self, callargs: List[str]) -> None: """Runs transcript tests for provided file(s). diff --git a/docs/freefeatures.rst b/docs/freefeatures.rst index a03a1d08..0a95a829 100644 --- a/docs/freefeatures.rst +++ b/docs/freefeatures.rst @@ -174,13 +174,9 @@ More Python examples: Type "help", "copyright", "credits" or "license" for more information. (CmdLineApp) - Invoke python command, shell, or script - - py <command>: Executes a Python command. - py: Enters interactive Python mode. - End with ``Ctrl-D`` (Unix) / ``Ctrl-Z`` (Windows), ``quit()``, '`exit()``. - Non-python commands can be issued with ``app("your command")``. - Run python code from external script files with ``run("script.py")`` + End with `Ctrl-D` (Unix) / `Ctrl-Z` (Windows), `quit()`, `exit()`. + Non-python commands can be issued with: app("your command") + Run python code from external script files with: run("script.py") >>> import os >>> os.uname() diff --git a/tests/conftest.py b/tests/conftest.py index 2c7a2600..da7e8b08 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -38,16 +38,16 @@ Documented commands (type help <topic>): ================================================================================ alias Manage aliases edit Edit a file in a text editor -help List available commands with "help" or detailed help with "help cmd" +help List available commands or provide detailed help for a specific command history View, run, edit, save, or clear previously entered commands -load Runs commands in script file that is encoded as either ASCII or UTF-8 text +load Run commands in script file that is encoded as either ASCII or UTF-8 text macro Manage macros -py Invoke python command, shell, or script -pyscript Runs a python script file inside the console -quit Exits this application -set Sets a settable parameter or shows current settings of parameters +py Invoke Python command or shell +pyscript Run a Python script file inside the console +quit Exit this application +set Set a settable parameter or show current settings of parameters shell Execute a command as if at the OS prompt -shortcuts Lists shortcuts available +shortcuts List available shortcuts """ # Help text for the history command @@ -66,12 +66,12 @@ 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 - -s, --script script format; no separation lines + -s, --script output commands in script format -o, --output-file FILE output commands to a script file -t, --transcript TRANSCRIPT output commands and results to a transcript file - -c, --clear clears all history + -c, --clear clear all history """ # Output from the shortcuts command with default built-in shortcuts diff --git a/tests/test_argparse.py b/tests/test_argparse.py index 0939859a..fdd16bcc 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -217,7 +217,7 @@ class SubcommandApp(cmd2.Cmd): func(self, args) else: # No subcommand was provided, so call help - self.do_help(['base']) + self.do_help('base') @pytest.fixture def subcommand_app(): diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index daca3087..3ce7a11d 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -70,7 +70,7 @@ def test_base_argparse_help(base_app, capsys): assert out1 == out2 assert out1[0].startswith('Usage: set') assert out1[1] == '' - assert out1[2].startswith('Sets a settable parameter') + assert out1[2].startswith('Set a settable parameter') def test_base_invalid_option(base_app, capsys): run_cmd(base_app, 'set -z') @@ -184,6 +184,30 @@ now: True assert out == ['quiet: True'] +class OnChangeHookApp(cmd2.Cmd): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def _onchange_quiet(self, old, new) -> None: + """Runs when quiet is changed via set command""" + self.poutput("You changed quiet") + +@pytest.fixture +def onchange_app(): + app = OnChangeHookApp() + app.stdout = utils.StdSim(app.stdout) + return app + +def test_set_onchange_hook(onchange_app): + out = run_cmd(onchange_app, 'set quiet True') + expected = normalize(""" +quiet - was: False +now: True +You changed quiet +""") + assert out == expected + + def test_base_shell(base_app, monkeypatch): m = mock.Mock() monkeypatch.setattr("{}.Popen".format('subprocess'), m) @@ -247,7 +271,7 @@ def test_pyscript_with_exception(base_app, capsys, request): def test_pyscript_requires_an_argument(base_app, capsys): run_cmd(base_app, "pyscript") out, err = capsys.readouterr() - assert err.startswith('ERROR: pyscript command requires at least 1 argument ...') + assert "the following arguments are required: script_path" in err def test_base_error(base_app): @@ -477,7 +501,7 @@ def test_load_with_empty_args(base_app, capsys): out, err = capsys.readouterr() # The load command requires a file path argument, so we should get an error message - assert "load command requires a file path" in str(err) + assert "the following arguments are required" in str(err) assert base_app.cmdqueue == [] @@ -614,8 +638,7 @@ def test_base_relative_load(base_app, request): def test_relative_load_requires_an_argument(base_app, capsys): run_cmd(base_app, '_relative_load') out, err = capsys.readouterr() - assert out == '' - assert err.startswith('ERROR: _relative_load command requires a file path:\n') + assert 'Error: the following arguments' in err assert base_app.cmdqueue == [] @@ -858,7 +881,8 @@ def test_edit_file(base_app, request, monkeypatch): run_cmd(base_app, 'edit {}'.format(filename)) # We think we have an editor, so should expect a system call - m.assert_called_once_with('"{}" "{}"'.format(base_app.editor, filename)) + m.assert_called_once_with('{} {}'.format(utils.quote_string_if_needed(base_app.editor), + utils.quote_string_if_needed(filename))) def test_edit_file_with_spaces(base_app, request, monkeypatch): # Set a fake editor just to make sure we have one. We aren't really going to call it due to the mock @@ -874,7 +898,8 @@ def test_edit_file_with_spaces(base_app, request, monkeypatch): run_cmd(base_app, 'edit "{}"'.format(filename)) # We think we have an editor, so should expect a system call - m.assert_called_once_with('"{}" "{}"'.format(base_app.editor, filename)) + m.assert_called_once_with('{} {}'.format(utils.quote_string_if_needed(base_app.editor), + utils.quote_string_if_needed(filename))) def test_edit_blank(base_app, monkeypatch): # Set a fake editor just to make sure we have one. We aren't really going to call it due to the mock @@ -1250,16 +1275,16 @@ diddly This command does diddly Other ================================================================================ alias Manage aliases -help List available commands with "help" or detailed help with "help cmd" +help List available commands or provide detailed help for a specific command history View, run, edit, save, or clear previously entered commands -load Runs commands in script file that is encoded as either ASCII or UTF-8 text +load Run commands in script file that is encoded as either ASCII or UTF-8 text macro Manage macros -py Invoke python command, shell, or script -pyscript Runs a python script file inside the console -quit Exits this application -set Sets a settable parameter or shows current settings of parameters +py Invoke Python command or shell +pyscript Run a Python script file inside the console +quit Exit this application +set Set a settable parameter or show current settings of parameters shell Execute a command as if at the OS prompt -shortcuts Lists shortcuts available +shortcuts List available shortcuts Undocumented commands: ====================== @@ -1556,7 +1581,7 @@ def test_is_text_file_bad_input(base_app): def test_eof(base_app): # Only thing to verify is that it returns True - assert base_app.do_eof('dont care') + assert base_app.do_eof('') def test_eos(base_app): sdir = 'dummy_dir' @@ -1564,7 +1589,7 @@ def test_eos(base_app): assert len(base_app._script_dir) == 1 # Assert that it does NOT return true - assert not base_app.do_eos('dont care') + assert not base_app.do_eos('') # And make sure it reduced the length of the script dir list assert len(base_app._script_dir) == 0 @@ -1803,7 +1828,7 @@ def test_alias_create(base_app, capsys): # Use the alias run_cmd(base_app, 'fake') out, err = capsys.readouterr() - assert "pyscript command requires at least 1 argument" in err + assert "the following arguments are required: script_path" in err # See a list of aliases out = run_cmd(base_app, 'alias list') @@ -1905,7 +1930,7 @@ def test_macro_create(base_app, capsys): # Use the macro run_cmd(base_app, 'fake') out, err = capsys.readouterr() - assert "pyscript command requires at least 1 argument" in err + assert "the following arguments are required: script_path" in err # See a list of macros out = run_cmd(base_app, 'macro list') diff --git a/tests/test_completion.py b/tests/test_completion.py index f02989bf..1b7b65d2 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -98,7 +98,7 @@ def test_complete_empty_arg(cmd2_app): endidx = len(line) begidx = endidx - len(text) - expected = sorted(cmd2_app.complete_help(text, line, begidx, endidx)) + expected = sorted(cmd2_app.get_visible_commands()) first_match = complete_tester(text, line, begidx, endidx, cmd2_app) assert first_match is not None and cmd2_app.completion_matches == expected @@ -151,7 +151,11 @@ def test_cmd2_help_completion_single(cmd2_app): line = 'help {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.complete_help(text, line, begidx, endidx) == ['help'] + + first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + + # It is at end of line, so extra space is present + assert first_match is not None and cmd2_app.completion_matches == ['help '] def test_cmd2_help_completion_multiple(cmd2_app): text = 'h' @@ -159,15 +163,18 @@ def test_cmd2_help_completion_multiple(cmd2_app): endidx = len(line) begidx = endidx - len(text) - matches = sorted(cmd2_app.complete_help(text, line, begidx, endidx)) - assert matches == ['help', 'history'] + first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + assert first_match is not None and cmd2_app.completion_matches == ['help', 'history'] + def test_cmd2_help_completion_nomatch(cmd2_app): text = 'fakecommand' line = 'help {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.complete_help(text, line, begidx, endidx) == [] + + first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + assert first_match is None def test_shell_command_completion_shortcut(cmd2_app): @@ -201,7 +208,9 @@ def test_shell_command_completion_doesnt_match_wildcards(cmd2_app): line = 'shell {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.complete_shell(text, line, begidx, endidx) == [] + + first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + assert first_match is None def test_shell_command_completion_multiple(cmd2_app): if sys.platform == "win32": @@ -214,21 +223,27 @@ def test_shell_command_completion_multiple(cmd2_app): line = 'shell {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert expected in cmd2_app.complete_shell(text, line, begidx, endidx) + + first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + assert first_match is not None and expected in cmd2_app.completion_matches def test_shell_command_completion_nomatch(cmd2_app): text = 'zzzz' line = 'shell {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.complete_shell(text, line, begidx, endidx) == [] + + first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + assert first_match is None def test_shell_command_completion_doesnt_complete_when_just_shell(cmd2_app): text = '' line = 'shell {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.complete_shell(text, line, begidx, endidx) == [] + + first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + assert first_match is None def test_shell_command_completion_does_path_completion_when_after_command(cmd2_app, request): test_dir = os.path.dirname(request.module.__file__) @@ -239,7 +254,8 @@ def test_shell_command_completion_does_path_completion_when_after_command(cmd2_a endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.complete_shell(text, line, begidx, endidx) == [text + '.py'] + first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + assert first_match is not None and cmd2_app.completion_matches == [text + '.py '] def test_path_completion_single_end(cmd2_app, request): @@ -742,7 +758,11 @@ def test_cmd2_help_subcommand_completion_single(sc_app): line = 'help {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert sc_app.complete_help(text, line, begidx, endidx) == ['base'] + + first_match = complete_tester(text, line, begidx, endidx, sc_app) + + # It is at end of line, so extra space is present + assert first_match is not None and sc_app.completion_matches == ['base '] def test_cmd2_help_subcommand_completion_multiple(sc_app): text = '' @@ -750,8 +770,8 @@ def test_cmd2_help_subcommand_completion_multiple(sc_app): endidx = len(line) begidx = endidx - len(text) - matches = sorted(sc_app.complete_help(text, line, begidx, endidx)) - assert matches == ['bar', 'foo', 'sport'] + first_match = complete_tester(text, line, begidx, endidx, sc_app) + assert first_match is not None and sc_app.completion_matches == ['bar', 'foo', 'sport'] def test_cmd2_help_subcommand_completion_nomatch(sc_app): @@ -759,7 +779,9 @@ def test_cmd2_help_subcommand_completion_nomatch(sc_app): line = 'help base {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert sc_app.complete_help(text, line, begidx, endidx) == [] + + first_match = complete_tester(text, line, begidx, endidx, sc_app) + assert first_match is None def test_subcommand_tab_completion(sc_app): # This makes sure the correct completer for the sport subcommand is called @@ -852,7 +874,7 @@ class SubcommandsWithUnknownExample(cmd2.Cmd): func(self, args) else: # No subcommand was provided, so call help - self.do_help(['base']) + self.do_help('base') @pytest.fixture @@ -901,7 +923,11 @@ def test_cmd2_help_subcommand_completion_single_scu(scu_app): line = 'help {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert scu_app.complete_help(text, line, begidx, endidx) == ['base'] + + first_match = complete_tester(text, line, begidx, endidx, scu_app) + + # It is at end of line, so extra space is present + assert first_match is not None and scu_app.completion_matches == ['base '] def test_cmd2_help_subcommand_completion_multiple_scu(scu_app): @@ -910,8 +936,34 @@ def test_cmd2_help_subcommand_completion_multiple_scu(scu_app): endidx = len(line) begidx = endidx - len(text) - matches = sorted(scu_app.complete_help(text, line, begidx, endidx)) - assert matches == ['bar', 'foo', 'sport'] + first_match = complete_tester(text, line, begidx, endidx, scu_app) + assert first_match is not None and scu_app.completion_matches == ['bar', 'foo', 'sport'] + +def test_cmd2_help_subcommand_completion_with_flags_before_command(scu_app): + text = '' + line = 'help -h -v base {}'.format(text) + endidx = len(line) + begidx = endidx - len(text) + + first_match = complete_tester(text, line, begidx, endidx, scu_app) + assert first_match is not None and scu_app.completion_matches == ['bar', 'foo', 'sport'] + +def test_complete_help_subcommand_with_no_command(scu_app): + # No command because not enough tokens + text = '' + line = 'help ' + endidx = len(line) + begidx = endidx - len(text) + + assert not scu_app.complete_help_subcommand(text, line, begidx, endidx) + + # No command because everything is a flag + text = '-v' + line = 'help -f -v' + endidx = len(line) + begidx = endidx - len(text) + + assert not scu_app.complete_help_subcommand(text, line, begidx, endidx) def test_cmd2_help_subcommand_completion_nomatch_scu(scu_app): @@ -919,7 +971,9 @@ def test_cmd2_help_subcommand_completion_nomatch_scu(scu_app): line = 'help base {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert scu_app.complete_help(text, line, begidx, endidx) == [] + + first_match = complete_tester(text, line, begidx, endidx, scu_app) + assert first_match == None def test_subcommand_tab_completion_scu(scu_app): diff --git a/tests/test_pyscript.py b/tests/test_pyscript.py index 84abc965..36e48598 100644 --- a/tests/test_pyscript.py +++ b/tests/test_pyscript.py @@ -18,16 +18,16 @@ class PyscriptExample(Cmd): def _do_media_movies(self, args) -> None: if not args.command: - self.do_help(['media movies']) + self.do_help('media movies') else: self.poutput('media movies ' + str(args.__dict__)) def _do_media_shows(self, args) -> None: if not args.command: - self.do_help(['media shows']) + self.do_help('media shows') if not args.command: - self.do_help(['media shows']) + self.do_help('media shows') else: self.poutput('media shows ' + str(args.__dict__)) @@ -72,7 +72,7 @@ class PyscriptExample(Cmd): func(self, args) else: # No subcommand was provided, so call help - self.do_help(['media']) + self.do_help('media') foo_parser = argparse_completer.ACArgumentParser(prog='foo') foo_parser.add_argument('-c', dest='counter', action='count') @@ -84,7 +84,7 @@ class PyscriptExample(Cmd): @with_argparser(foo_parser) def do_foo(self, args): - self.poutput('foo ' + str(args.__dict__)) + self.poutput('foo ' + str(sorted(args.__dict__))) if self._in_py: FooResult = namedtuple_with_defaults('FooResult', ['counter', 'trueval', 'constval', |