diff options
Diffstat (limited to 'cmd2/cmd2.py')
-rw-r--r-- | cmd2/cmd2.py | 227 |
1 files changed, 146 insertions, 81 deletions
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index dbdb55ef..adadbdf8 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -99,6 +99,9 @@ COMMAND_FUNC_PREFIX = 'do_' # All help functions start with this HELP_FUNC_PREFIX = 'help_' +# All command completer functions start with this +COMPLETER_FUNC_PREFIX = 'complete_' + # Sorting keys for strings ALPHABETICAL_SORT_KEY = utils.norm_fold NATURAL_SORT_KEY = utils.natural_keys @@ -164,8 +167,10 @@ def with_argument_list(*args: List[Callable], preserve_quotes: bool = False) -> return cmd_wrapper if len(args) == 1 and callable(args[0]): + # noinspection PyTypeChecker return arg_decorator(args[0]) else: + # noinspection PyTypeChecker return arg_decorator @@ -188,7 +193,6 @@ def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser, *, """ import functools - # noinspection PyProtectedMember def arg_decorator(func: Callable): @functools.wraps(func) def cmd_wrapper(cmd2_app, statement: Union[Statement, str]): @@ -226,6 +230,7 @@ def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser, *, return cmd_wrapper + # noinspection PyTypeChecker return arg_decorator @@ -246,7 +251,6 @@ def with_argparser(argparser: argparse.ArgumentParser, *, """ import functools - # noinspection PyProtectedMember def arg_decorator(func: Callable): @functools.wraps(func) def cmd_wrapper(cmd2_app, statement: Union[Statement, str]): @@ -284,6 +288,7 @@ def with_argparser(argparser: argparse.ArgumentParser, *, return cmd_wrapper + # noinspection PyTypeChecker return arg_decorator @@ -316,7 +321,7 @@ class EmptyStatement(Exception): # Contains data about a disabled command which is used to restore its original functions when the command is enabled -DisabledCommand = namedtuple('DisabledCommand', ['command_function', 'help_function']) +DisabledCommand = namedtuple('DisabledCommand', ['command_function', 'help_function', 'completer_function']) class Cmd(cmd.Cmd): @@ -427,14 +432,14 @@ class Cmd(cmd.Cmd): # Defines app-specific variables/functions available in Python shells and pyscripts self.py_locals = {} + # True if running inside a Python script or interactive console, False otherwise + self._in_py = False + self.statement_parser = StatementParser(allow_redirection=allow_redirection, terminators=terminators, multiline_commands=multiline_commands, shortcuts=shortcuts) - # True if running inside a Python script or interactive console, False otherwise - self._in_py = False - # Stores results from the last command run to enable usage of results in a Python script or interactive console # Built-in commands don't make use of this. It is purely there for user-defined commands and convenience. self.last_result = None @@ -1459,10 +1464,14 @@ class Cmd(cmd.Cmd): text = text_to_remove + text begidx = actual_begidx - # Check if a valid command was entered - if command in self.get_all_commands(): + # Check if a macro was entered + if command in self.macros: + compfunc = self.path_complete + + # Check if a command was entered + elif command in self.get_all_commands(): # Get the completer function for this command - compfunc = getattr(self, 'complete_' + command, None) + compfunc = getattr(self, COMPLETER_FUNC_PREFIX + command, None) if compfunc is None: # There's no completer function, next see if the command uses argparse @@ -1476,11 +1485,7 @@ class Cmd(cmd.Cmd): else: compfunc = self.completedefault - # Check if a macro was entered - elif command in self.macros: - compfunc = self.path_complete - - # A valid command was not entered + # Not a recognized macro or command else: # Check if this command should be run as a shell command if self.default_to_shell and command in utils.get_exes_in_path(command): @@ -1724,10 +1729,13 @@ class Cmd(cmd.Cmd): statement = self.statement_parser.parse_command_only(line) return statement.command, statement.args, statement.command_and_args - def onecmd_plus_hooks(self, line: str, *, add_to_history: bool = True, py_bridge_call: bool = False) -> bool: + def onecmd_plus_hooks(self, line: str, *, expand: bool = True, add_to_history: bool = True, + py_bridge_call: bool = False) -> bool: """Top-level function called by cmdloop() to handle parsing a line and running the command and all of its hooks. - :param line: line of text read from input + :param line: command line to run + :param expand: If True, then aliases, macros, and shortcuts will be expanded. + Set this to False if the command token should not be altered. Defaults to True. :param add_to_history: If True, then add this command to history. Defaults to True. :param py_bridge_call: This should only ever be set to True by PyBridge to signify the beginning of an app() call from Python. It is used to enable/disable the storage of the @@ -1738,7 +1746,7 @@ class Cmd(cmd.Cmd): stop = False try: - statement = self._input_line_to_statement(line) + statement = self._input_line_to_statement(line, expand=expand) except EmptyStatement: return self._run_cmdfinalization_hooks(stop, None) except ValueError as ex: @@ -1853,12 +1861,15 @@ class Cmd(cmd.Cmd): except Exception as ex: self.pexcept(ex) - def runcmds_plus_hooks(self, cmds: List[Union[HistoryItem, str]], *, add_to_history: bool = True) -> bool: + def runcmds_plus_hooks(self, cmds: List[Union[HistoryItem, str]], *, + expand: bool = True, add_to_history: bool = True) -> bool: """ Used when commands are being run in an automated fashion like text scripts or history replays. The prompt and command line for each command will be printed if echo is True. :param cmds: commands to run + :param expand: If True, then aliases, macros, and shortcuts will be expanded. + Set this to False if the command token should not be altered. Defaults to True. :param add_to_history: If True, then add these commands to history. Defaults to True. :return: True if running of commands should stop """ @@ -1869,12 +1880,12 @@ class Cmd(cmd.Cmd): if self.echo: self.poutput('{}{}'.format(self.prompt, line)) - if self.onecmd_plus_hooks(line, add_to_history=add_to_history): + if self.onecmd_plus_hooks(line, expand=expand, add_to_history=add_to_history): return True return False - def _complete_statement(self, line: str) -> Statement: + def _complete_statement(self, line: str, *, expand: bool = True) -> Statement: """Keep accepting lines of input until the command is complete. There is some pretty hacky code here to handle some quirks of @@ -1883,11 +1894,13 @@ class Cmd(cmd.Cmd): backwards compatibility with the standard library version of cmd. :param line: the line being parsed + :param expand: If True, then aliases and shortcuts will be expanded. + Set this to False if the command token should not be altered. Defaults to True. :return: the completed Statement """ while True: try: - statement = self.statement_parser.parse(line) + statement = self.statement_parser.parse(line, expand=expand) if statement.multiline_command and statement.terminator: # we have a completed multiline command, we are done break @@ -1898,7 +1911,7 @@ class Cmd(cmd.Cmd): except ValueError: # we have unclosed quotation marks, lets parse only the command # and see if it's a multiline - statement = self.statement_parser.parse_command_only(line) + statement = self.statement_parser.parse_command_only(line, expand=expand) if not statement.multiline_command: # not a multiline command, so raise the exception raise @@ -1935,11 +1948,13 @@ class Cmd(cmd.Cmd): raise EmptyStatement() return statement - def _input_line_to_statement(self, line: str) -> Statement: + def _input_line_to_statement(self, line: str, *, expand: bool = True) -> Statement: """ Parse the user's input line and convert it to a Statement, ensuring that all macros are also resolved :param line: the line being parsed + :param expand: If True, then aliases, macros, and shortcuts will be expanded. + Set this to False if the command token should not be altered. Defaults to True. :return: parsed command line as a Statement """ used_macros = [] @@ -1948,14 +1963,14 @@ class Cmd(cmd.Cmd): # Continue until all macros are resolved while True: # Make sure all input has been read and convert it to a Statement - statement = self._complete_statement(line) + statement = self._complete_statement(line, expand=expand) # Save the fully entered line if this is the first loop iteration if orig_line is None: orig_line = statement.raw # Check if this command matches a macro and wasn't already processed to avoid an infinite loop - if statement.command in self.macros.keys() and statement.command not in used_macros: + if expand and statement.command in self.macros.keys() and statement.command not in used_macros: used_macros.append(statement.command) line = self._resolve_macro(statement) if line is None: @@ -1976,8 +1991,7 @@ class Cmd(cmd.Cmd): suffix=statement.suffix, pipe_to=statement.pipe_to, output=statement.output, - output_to=statement.output_to, - ) + output_to=statement.output_to) return statement def _resolve_macro(self, statement: Statement) -> Optional[str]: @@ -2170,19 +2184,22 @@ class Cmd(cmd.Cmd): return target if callable(getattr(self, target, None)) else '' # noinspection PyMethodOverriding - def onecmd(self, statement: Union[Statement, str], *, add_to_history: bool = True) -> bool: + def onecmd(self, statement: Union[Statement, str], *, + expand: bool = True, add_to_history: bool = True) -> bool: """ This executes the actual do_* method for a command. If the command provided doesn't exist, then it executes default() instead. :param statement: intended to be a Statement instance parsed command from the input stream, alternative acceptance of a str is present only for backward compatibility with cmd + :param expand: If True, then aliases, macros, and shortcuts will be expanded. + Set this to False if the command token should not be altered. Defaults to True. :param add_to_history: If True, then add this command to history. Defaults to True. :return: a flag indicating whether the interpretation of commands should stop """ # For backwards compatibility with cmd, allow a str to be passed in if not isinstance(statement, Statement): - statement = self._input_line_to_statement(statement) + statement = self._input_line_to_statement(statement, expand=expand) func = self.cmd_func(statement.command) if func: @@ -2211,6 +2228,7 @@ class Cmd(cmd.Cmd): if 'shell' not in self.exclude_from_history: self.history.append(statement) + # noinspection PyTypeChecker return self.do_shell(statement.command_and_args) else: err_msg = self.default_error.format(statement.command) @@ -2486,7 +2504,7 @@ class Cmd(cmd.Cmd): # Call whatever subcommand function was selected func(self, args) else: - # No subcommand was provided, so call help + # noinspection PyTypeChecker self.do_help('alias') # ----- Macro subcommand functions ----- @@ -2500,8 +2518,8 @@ class Cmd(cmd.Cmd): self.perror("Invalid macro name: {}".format(errmsg)) return - if args.name in self.get_all_commands(): - self.perror("Macro cannot have the same name as a command") + if args.name in self.statement_parser.multiline_commands: + self.perror("Macro cannot have the same name as a multiline command") return if args.name in self.aliases: @@ -2688,7 +2706,7 @@ class Cmd(cmd.Cmd): # Call whatever subcommand function was selected func(self, args) else: - # No subcommand was provided, so call help + # noinspection PyTypeChecker self.do_help('macro') def complete_help_command(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: @@ -2737,7 +2755,8 @@ class Cmd(cmd.Cmd): return matches - help_parser = Cmd2ArgumentParser() + help_parser = Cmd2ArgumentParser(description="List available commands or provide " + "detailed help for a specific command") help_parser.add_argument('command', nargs=argparse.OPTIONAL, help="command to retrieve help for", completer_method=complete_help_command) help_parser.add_argument('subcommand', nargs=argparse.REMAINDER, help="subcommand to retrieve help for", @@ -2906,7 +2925,7 @@ class Cmd(cmd.Cmd): command = '' self.stdout.write("\n") - @with_argparser(Cmd2ArgumentParser()) + @with_argparser(Cmd2ArgumentParser(description="List available shortcuts")) def do_shortcuts(self, _: argparse.Namespace) -> None: """List available shortcuts""" # Sort the shortcut tuples by name @@ -2920,7 +2939,7 @@ class Cmd(cmd.Cmd): # Return True to stop the command loop return True - @with_argparser(Cmd2ArgumentParser()) + @with_argparser(Cmd2ArgumentParser(description="Exit this application")) def do_quit(self, _: argparse.Namespace) -> bool: """Exit this application""" # Return True to stop the command loop @@ -3060,7 +3079,7 @@ class Cmd(cmd.Cmd): if onchange_hook is not None: onchange_hook(old=orig_value, new=new_value) # pylint: disable=not-callable - shell_parser = Cmd2ArgumentParser() + shell_parser = Cmd2ArgumentParser(description="Execute a command as if at the OS prompt") shell_parser.add_argument('command', help='the command to run', completer_method=shell_cmd_complete) shell_parser.add_argument('command_args', nargs=argparse.REMAINDER, help='arguments to pass to command', completer_method=path_complete) @@ -3229,42 +3248,59 @@ class Cmd(cmd.Cmd): py_parser.add_argument('command', nargs=argparse.OPTIONAL, help="command to run") py_parser.add_argument('remainder', nargs=argparse.REMAINDER, help="remainder of command") + # This is a hidden flag for telling do_py to run a pyscript. It is intended only to be used by run_pyscript + # after it sets up sys.argv for the script being run. When this flag is present, it takes precedence over all + # other arguments. run_pyscript uses this method instead of "py run('file')" because file names with + # 2 or more consecutive spaces cause issues with our parser, which isn't meant to parse Python statements. + py_parser.add_argument('--pyscript', help=argparse.SUPPRESS) + # Preserve quotes since we are passing these strings to Python @with_argparser(py_parser, preserve_quotes=True) - def do_py(self, args: argparse.Namespace) -> bool: - """Invoke Python command or shell""" + def do_py(self, args: argparse.Namespace) -> Optional[bool]: + """ + Enter an interactive Python shell + :return: True if running of commands should stop + """ from .py_bridge import PyBridge if self._in_py: err = "Recursively entering interactive Python consoles is not allowed." self.perror(err) - return False + return py_bridge = PyBridge(self) + py_code_to_run = '' + + # Handle case where we were called by run_pyscript + if args.pyscript: + args.pyscript = utils.strip_quotes(args.pyscript) + + # Run the script - use repr formatting to escape things which + # need to be escaped to prevent issues on Windows + py_code_to_run = 'run({!r})'.format(args.pyscript) + + elif args.command: + py_code_to_run = args.command + if args.remainder: + py_code_to_run += ' ' + ' '.join(args.remainder) + + # Set cmd_echo to True so PyBridge statements like: py app('help') + # run at the command line will print their output. + py_bridge.cmd_echo = True try: self._in_py = True - # Support the run command even if called prior to invoking an interactive interpreter def py_run(filename: str): """Run a Python script file in the interactive console. - :param filename: filename of *.py script file to run + :param filename: filename of script file to run """ expanded_filename = os.path.expanduser(filename) - if not expanded_filename.endswith('.py'): - self.pwarning("'{}' does not have a .py extension".format(expanded_filename)) - selection = self.select('Yes No', 'Continue to try to run it as a Python script? ') - if selection != 'Yes': - return - - # cmd_echo defaults to False for scripts. The user can always toggle this value in their script. - py_bridge.cmd_echo = False - try: with open(expanded_filename) as f: interp.runcode(f.read()) except OSError as ex: - self.pexcept("Error opening script file '{}': {}".format(expanded_filename, ex)) + self.pexcept("Error reading script file '{}': {}".format(expanded_filename, ex)) def py_quit(): """Function callable from the interactive Python console to exit that environment""" @@ -3285,24 +3321,16 @@ 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) - - # Set cmd_echo to True so PyBridge statements like: py app('help') - # run at the command line will print their output. - py_bridge.cmd_echo = True - + # Check if we are running Python code + if py_code_to_run: # noinspection PyBroadException try: - interp.runcode(full_command) + interp.runcode(py_code_to_run) except BaseException: - # We don't care about any exception that happened in the interactive console + # We don't care about any exception that happened in the Python code pass - # If there are no args, then we will open an interactive Python shell + # Otherwise we will open an interactive Python shell else: cprt = 'Type "help", "copyright", "credits" or "license" for more information.' instructions = ('End with `Ctrl-D` (Unix) / `Ctrl-Z` (Windows), `quit()`, `exit()`.\n' @@ -3338,15 +3366,28 @@ class Cmd(cmd.Cmd): return py_bridge.stop - run_pyscript_parser = Cmd2ArgumentParser() + run_pyscript_parser = Cmd2ArgumentParser(description="Run a Python script file inside the console") run_pyscript_parser.add_argument('script_path', help='path to the script file', completer_method=path_complete) run_pyscript_parser.add_argument('script_arguments', nargs=argparse.REMAINDER, help='arguments to pass to script', completer_method=path_complete) @with_argparser(run_pyscript_parser) - def do_run_pyscript(self, args: argparse.Namespace) -> bool: - """Run a Python script file inside the console""" - script_path = os.path.expanduser(args.script_path) + def do_run_pyscript(self, args: argparse.Namespace) -> Optional[bool]: + """ + Run a Python script file inside the console + :return: True if running of commands should stop + """ + # Expand ~ before placing this path in sys.argv just as a shell would + args.script_path = os.path.expanduser(args.script_path) + + # Add some protection against accidentally running a non-Python file. The happens when users + # mix up run_script and run_pyscript. + if not args.script_path.endswith('.py'): + self.pwarning("'{}' does not have a .py extension".format(args.script_path)) + selection = self.select('Yes No', 'Continue to try to run it as a Python script? ') + if selection != 'Yes': + return + py_return = False # Save current command line arguments @@ -3354,11 +3395,10 @@ class Cmd(cmd.Cmd): try: # Overwrite sys.argv to allow the script to take command line arguments - sys.argv = [script_path] + args.script_arguments + sys.argv = [args.script_path] + args.script_arguments - # Run the script - use repr formatting to escape things which - # need to be escaped to prevent issues on Windows - py_return = self.do_py("run({!r})".format(script_path)) + # noinspection PyTypeChecker + py_return = self.do_py('--pyscript {}'.format(utils.quote_string(args.script_path))) except KeyboardInterrupt: pass @@ -3371,7 +3411,7 @@ class Cmd(cmd.Cmd): # Only include the do_ipy() method if IPython is available on the system if ipython_available: # pragma: no cover - @with_argparser(Cmd2ArgumentParser()) + @with_argparser(Cmd2ArgumentParser(description="Enter an interactive IPython shell")) def do_ipy(self, _: argparse.Namespace) -> None: """Enter an interactive IPython shell""" from .py_bridge import PyBridge @@ -3513,8 +3553,14 @@ class Cmd(cmd.Cmd): else: fobj.write('{}\n'.format(command.raw)) try: - self.do_edit(fname) - return self.do_run_script(fname) + # Handle potential edge case where the temp file needs to be quoted on the command line + quoted_fname = utils.quote_string(fname) + + # noinspection PyTypeChecker + self.do_edit(quoted_fname) + + # noinspection PyTypeChecker + self.do_run_script(quoted_fname) finally: os.remove(fname) elif args.output_file: @@ -3711,10 +3757,11 @@ class Cmd(cmd.Cmd): if not self.editor: raise EnvironmentError("Please use 'set editor' to specify your text editing program of choice.") - command = utils.quote_string_if_needed(os.path.expanduser(self.editor)) + command = utils.quote_string(os.path.expanduser(self.editor)) if args.file_path: - command += " " + utils.quote_string_if_needed(os.path.expanduser(args.file_path)) + command += " " + utils.quote_string(os.path.expanduser(args.file_path)) + # noinspection PyTypeChecker self.do_shell(command) @property @@ -3767,6 +3814,8 @@ class Cmd(cmd.Cmd): self.perror("'{}' is not an ASCII or UTF-8 encoded text file".format(expanded_path)) return + # Add some protection against accidentally running a Python file. The happens when users + # mix up run_script and run_pyscript. if expanded_path.endswith('.py'): self.pwarning("'{}' appears to be a Python file".format(expanded_path)) selection = self.select('Yes No', 'Continue to try to run it as a text script? ') @@ -3819,7 +3868,9 @@ class Cmd(cmd.Cmd): 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) - return self.do_run_script(relative_path) + + # noinspection PyTypeChecker + return self.do_run_script(utils.quote_string(relative_path)) def _run_transcript_tests(self, transcript_paths: List[str]) -> None: """Runs transcript tests for provided file(s). @@ -3858,6 +3909,7 @@ class Cmd(cmd.Cmd): sys.argv = [sys.argv[0]] # the --test argument upsets unittest.main() testcase = TestMyAppCase() stream = utils.StdSim(sys.stderr) + # noinspection PyTypeChecker runner = unittest.TextTestRunner(stream=stream) start_time = time.time() test_results = runner.run(testcase) @@ -3999,16 +4051,24 @@ class Cmd(cmd.Cmd): return help_func_name = HELP_FUNC_PREFIX + command + completer_func_name = COMPLETER_FUNC_PREFIX + command - # Restore the command and help functions to their original values + # Restore the command function to its original value dc = self.disabled_commands[command] setattr(self, self._cmd_func_name(command), dc.command_function) + # Restore the help function to its original value if dc.help_function is None: delattr(self, help_func_name) else: setattr(self, help_func_name, dc.help_function) + # Restore the completer function to its original value + if dc.completer_function is None: + delattr(self, completer_func_name) + else: + setattr(self, completer_func_name, dc.completer_function) + # Remove the disabled command entry del self.disabled_commands[command] @@ -4044,10 +4104,12 @@ class Cmd(cmd.Cmd): raise AttributeError("{} does not refer to a command".format(command)) help_func_name = HELP_FUNC_PREFIX + command + completer_func_name = COMPLETER_FUNC_PREFIX + command # Add the disabled command record self.disabled_commands[command] = DisabledCommand(command_function=command_function, - help_function=getattr(self, help_func_name, None)) + help_function=getattr(self, help_func_name, None), + completer_function=getattr(self, completer_func_name, None)) # Overwrite the command and help functions to print the message new_func = functools.partial(self._report_disabled_command_usage, @@ -4055,6 +4117,9 @@ class Cmd(cmd.Cmd): setattr(self, self._cmd_func_name(command), new_func) setattr(self, help_func_name, new_func) + # Set the completer to a function that returns a blank list + setattr(self, completer_func_name, lambda *args, **kwargs: []) + def disable_category(self, category: str, message_to_print: str) -> None: """Disable an entire category of commands. |