diff options
-rw-r--r-- | CHANGELOG.md | 2 | ||||
-rw-r--r-- | CONTRIBUTING.md | 4 | ||||
-rw-r--r-- | cmd2/cmd2.py | 227 | ||||
-rw-r--r-- | cmd2/parsing.py | 34 | ||||
-rw-r--r-- | cmd2/utils.py | 17 | ||||
-rwxr-xr-x[-rw-r--r--] | examples/migrating.py | 0 | ||||
-rwxr-xr-x | examples/unicode_commands.py | 27 | ||||
-rw-r--r-- | tests/pyscript/raises_exception.py (renamed from tests/scripts/raises_exception.py) | 0 | ||||
-rw-r--r-- | tests/pyscript/recursive.py | 11 | ||||
-rw-r--r-- | tests/pyscript/run.py | 6 | ||||
-rw-r--r-- | tests/pyscript/to_run.py | 2 | ||||
-rw-r--r-- | tests/scripts/recursive.py | 8 | ||||
-rw-r--r-- | tests/test_cmd2.py | 222 | ||||
-rw-r--r-- | tests/test_history.py | 15 | ||||
-rw-r--r-- | tests/test_run_pyscript.py | 43 | ||||
-rw-r--r-- | tests/test_utils.py | 12 |
16 files changed, 457 insertions, 173 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 98f04eeb..f9622e62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ * Fixed bug where multiline commands were having leading and ending spaces stripped. This would mess up quoted strings that crossed multiple lines. * Fixed a bug when appending to the clipboard where contents were in reverse order + * Fixed issue where run_pyscript failed if the script's filename had 2 or more consecutive spaces + * Fixed issue where completer function of disabled command would still run * Enhancements * Greatly simplified using argparse-based tab completion. The new interface is a complete overhaul that breaks the previous way of specifying completion and choices functions. See header of [argparse_custom.py](https://github.com/python-cmd2/cmd2/blob/master/cmd2/argparse_custom.py) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e0ae7625..6055fa7d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -192,7 +192,7 @@ $ git push origin [name_of_your_new_branch] ### Setting up for cmd2 development -For doing cmd2 development, it is recommended you create a virutal environment using Conda or Virtualenv and install the +For doing cmd2 development, it is recommended you create a virtual environment using Conda or Virtualenv and install the package from the source. #### Create a new environment for cmd2 using Pipenv @@ -234,7 +234,7 @@ pyenv versions # Install python version defined pyenv install 3.6.3 ``` -With the Python version installed, you can set the virutalenv properly. +With the Python version installed, you can set the virtualenv properly. ```sh $ cd ~/src/cmd2 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. diff --git a/cmd2/parsing.py b/cmd2/parsing.py index dbfabc80..dfa248e9 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -359,15 +359,13 @@ class StatementParser: errmsg = '' return valid, errmsg - def tokenize(self, line: str, expand: bool = True) -> List[str]: + def tokenize(self, line: str, *, expand: bool = True) -> List[str]: """ Lex a string into a list of tokens. Shortcuts and aliases are expanded and comments are removed :param line: the command line being lexed :param expand: If True, then aliases and shortcuts will be expanded. - Set this to False if no expansion should occur because the command name is already known. - Otherwise the command could be expanded if it matched an alias name. This is for cases where - a do_* method was called manually (e.g do_help('alias'). + Set this to False if the command token should not be altered. Defaults to True. :return: A list of tokens :raises ValueError if there are unclosed quotation marks. """ @@ -387,7 +385,7 @@ class StatementParser: tokens = self._split_on_punctuation(tokens) return tokens - def parse(self, line: str, expand: bool = True) -> Statement: + def parse(self, line: str, *, expand: bool = True) -> Statement: """ Tokenize the input and parse it into a Statement object, stripping comments, expanding aliases and shortcuts, and extracting output @@ -395,10 +393,8 @@ class StatementParser: :param line: the command line being parsed :param expand: If True, then aliases and shortcuts will be expanded. - Set this to False if no expansion should occur because the command name is already known. - Otherwise the command could be expanded if it matched an alias name. This is for cases where - a do_* method was called manually (e.g do_help('alias'). - :return: A parsed Statement + Set this to False if the command token should not be altered. Defaults to True. + :return: the created Statement :raises ValueError if there are unclosed quotation marks """ @@ -414,7 +410,7 @@ class StatementParser: arg_list = [] # lex the input into a list of tokens - tokens = self.tokenize(line, expand) + tokens = self.tokenize(line, expand=expand) # of the valid terminators, find the first one to occur in the input terminator_pos = len(tokens) + 1 @@ -533,11 +529,10 @@ class StatementParser: suffix=suffix, pipe_to=pipe_to, output=output, - output_to=output_to, - ) + output_to=output_to) return statement - def parse_command_only(self, rawinput: str) -> Statement: + def parse_command_only(self, rawinput: str, *, expand: bool = True) -> Statement: """Partially parse input into a Statement object. The command is identified, and shortcuts and aliases are expanded. @@ -560,9 +555,17 @@ class StatementParser: Different from parse(), this method does not remove redundant whitespace within args. However, it does ensure args has no leading or trailing whitespace. + + :param rawinput: the command line as entered by the user + :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 created Statement """ + line = rawinput + # expand shortcuts and aliases - line = self._expand(rawinput) + if expand: + line = self._expand(rawinput) command = '' args = '' @@ -591,8 +594,7 @@ class StatementParser: statement = Statement(args, raw=rawinput, command=command, - multiline_command=multiline_command, - ) + multiline_command=multiline_command) return statement def get_command_arg_list(self, command_name: str, to_parse: Union[Statement, str], diff --git a/cmd2/utils.py b/cmd2/utils.py index b19c2b49..d0ce10bc 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -25,11 +25,8 @@ def is_quoted(arg: str) -> bool: return len(arg) > 1 and arg[0] == arg[-1] and arg[0] in constants.QUOTES -def quote_string_if_needed(arg: str) -> str: - """ Quotes a string if it contains spaces and isn't already quoted """ - if is_quoted(arg) or ' ' not in arg: - return arg - +def quote_string(arg: str) -> str: + """Quote a string""" if '"' in arg: quote = "'" else: @@ -38,8 +35,16 @@ def quote_string_if_needed(arg: str) -> str: return quote + arg + quote +def quote_string_if_needed(arg: str) -> str: + """Quote a string if it contains spaces and isn't already quoted""" + if is_quoted(arg) or ' ' not in arg: + return arg + + return quote_string(arg) + + def strip_quotes(arg: str) -> str: - """ Strip outer quotes from a string. + """Strip outer quotes from a string. Applies to both single and double quotes. diff --git a/examples/migrating.py b/examples/migrating.py index 3a25b8c8..3a25b8c8 100644..100755 --- a/examples/migrating.py +++ b/examples/migrating.py diff --git a/examples/unicode_commands.py b/examples/unicode_commands.py new file mode 100755 index 00000000..f8381e50 --- /dev/null +++ b/examples/unicode_commands.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python +# coding=utf-8 +"""A simple example demonstrating support for unicode command names. +""" +import math +import cmd2 + + +class UnicodeApp(cmd2.Cmd): + """Example cmd2 application with unicode command names.""" + + def __init__(self): + super().__init__() + self.intro = 'Welcome the Unicode example app. Note the full Unicode support: 😇 💩' + + def do_𝛑print(self, _): + """This command prints 𝛑 to 5 decimal places.""" + self.poutput("𝛑 = {0:.6}".format(math.pi)) + + def do_你好(self, arg): + """This command says hello in Chinese (Mandarin).""" + self.poutput("你好 " + arg) + + +if __name__ == '__main__': + app = UnicodeApp() + app.cmdloop() diff --git a/tests/scripts/raises_exception.py b/tests/pyscript/raises_exception.py index 738edaf2..738edaf2 100644 --- a/tests/scripts/raises_exception.py +++ b/tests/pyscript/raises_exception.py diff --git a/tests/pyscript/recursive.py b/tests/pyscript/recursive.py new file mode 100644 index 00000000..21550592 --- /dev/null +++ b/tests/pyscript/recursive.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python +# coding=utf-8 +# flake8: noqa F821 +""" +Example demonstrating that calling run_pyscript recursively inside another Python script isn't allowed +""" +import os + +app.cmd_echo = True +my_dir = (os.path.dirname(os.path.realpath(sys.argv[0]))) +app('run_pyscript {}'.format(os.path.join(my_dir, 'stop.py'))) diff --git a/tests/pyscript/run.py b/tests/pyscript/run.py new file mode 100644 index 00000000..47250a10 --- /dev/null +++ b/tests/pyscript/run.py @@ -0,0 +1,6 @@ +# flake8: noqa F821 +import os + +app.cmd_echo = True +my_dir = (os.path.dirname(os.path.realpath(sys.argv[0]))) +run(os.path.join(my_dir, 'to_run.py')) diff --git a/tests/pyscript/to_run.py b/tests/pyscript/to_run.py new file mode 100644 index 00000000..b207952d --- /dev/null +++ b/tests/pyscript/to_run.py @@ -0,0 +1,2 @@ +# flake8: noqa F821 +print("I have been run") diff --git a/tests/scripts/recursive.py b/tests/scripts/recursive.py deleted file mode 100644 index 7d37e540..00000000 --- a/tests/scripts/recursive.py +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env python -# coding=utf-8 -# flake8: noqa F821 -""" -Example demonstrating that running a Python script recursively inside another Python script isn't allowed -""" -app.cmd_echo = True -app('run_pyscript ../script.py') diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index d4dbfe55..16c5eed4 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -5,11 +5,11 @@ Cmd2 unit/functional testing """ import argparse import builtins -from code import InteractiveConsole import io import os import sys import tempfile +from code import InteractiveConsole import pytest @@ -21,7 +21,8 @@ except ImportError: import cmd2 from cmd2 import ansi, clipboard, constants, utils -from .conftest import run_cmd, normalize, verify_help_text, HELP_HISTORY, SHORTCUTS_TXT, SHOW_TXT, SHOW_LONG +from .conftest import run_cmd, normalize, verify_help_text, HELP_HISTORY +from .conftest import SHORTCUTS_TXT, SHOW_TXT, SHOW_LONG, complete_tester def CreateOutsimApp(): c = cmd2.Cmd() @@ -236,6 +237,7 @@ def test_base_shell(base_app, monkeypatch): assert out == [] assert m.called + def test_base_py(base_app): # Create a variable and make sure we can see it out, err = run_cmd(base_app, 'py qqq=3') @@ -263,17 +265,6 @@ def test_base_py(base_app): assert "NameError: name 'self' is not defined" in err -@pytest.mark.skipif(sys.platform == 'win32', - reason="Unit test doesn't work on win32, but feature does") -def test_py_run_script(base_app, request): - test_dir = os.path.dirname(request.module.__file__) - python_script = os.path.join(test_dir, 'script.py') - expected = 'This is a python script running ...' - - out, err = run_cmd(base_app, "py run('{}')".format(python_script)) - assert expected in out - - def test_base_error(base_app): out, err = run_cmd(base_app, 'meow') assert "is not a recognized command" in err[0] @@ -436,6 +427,32 @@ def test_relative_run_script(base_app, request): assert script_out == manual_out assert script_err == manual_err +def test_relative_run_script_with_odd_file_names(base_app, monkeypatch): + """Test file names with various patterns""" + # Mock out the do_run_script call to see what args are passed to it + run_script_mock = mock.MagicMock(name='do_run_script') + monkeypatch.setattr("cmd2.Cmd.do_run_script", run_script_mock) + + file_name = utils.quote_string('nothingweird.txt') + out, err = run_cmd(base_app, "_relative_run_script {}".format(file_name)) + run_script_mock.assert_called_once_with('"nothingweird.txt"') + run_script_mock.reset_mock() + + file_name = utils.quote_string('has spaces.txt') + out, err = run_cmd(base_app, "_relative_run_script {}".format(file_name)) + run_script_mock.assert_called_once_with('"has spaces.txt"') + run_script_mock.reset_mock() + + file_name = utils.quote_string('"is_double_quoted.txt"') + out, err = run_cmd(base_app, "_relative_run_script {}".format(file_name)) + run_script_mock.assert_called_once_with('\'"is_double_quoted.txt"\'') + run_script_mock.reset_mock() + + file_name = utils.quote_string("'is_single_quoted.txt'") + out, err = run_cmd(base_app, "_relative_run_script {}".format(file_name)) + run_script_mock.assert_called_once_with('"\'is_single_quoted.txt\'"') + run_script_mock.reset_mock() + def test_relative_run_script_requires_an_argument(base_app): out, err = run_cmd(base_app, '_relative_run_script') assert 'Error: the following arguments' in err[1] @@ -665,6 +682,36 @@ def test_edit_file(base_app, request, monkeypatch): # We think we have an editor, so should expect a Popen call m.assert_called_once() +def test_edit_file_with_odd_file_names(base_app, monkeypatch): + """Test editor and file names with various patterns""" + # Mock out the do_shell call to see what args are passed to it + shell_mock = mock.MagicMock(name='do_shell') + monkeypatch.setattr("cmd2.Cmd.do_shell", shell_mock) + + base_app.editor = 'fooedit' + file_name = utils.quote_string('nothingweird.py') + out, err = run_cmd(base_app, "edit {}".format(file_name)) + shell_mock.assert_called_once_with('"fooedit" "nothingweird.py"') + shell_mock.reset_mock() + + base_app.editor = 'foo edit' + file_name = utils.quote_string('has spaces.py') + out, err = run_cmd(base_app, "edit {}".format(file_name)) + shell_mock.assert_called_once_with('"foo edit" "has spaces.py"') + shell_mock.reset_mock() + + base_app.editor = '"fooedit"' + file_name = utils.quote_string('"is_double_quoted.py"') + out, err = run_cmd(base_app, "edit {}".format(file_name)) + shell_mock.assert_called_once_with('\'"fooedit"\' \'"is_double_quoted.py"\'') + shell_mock.reset_mock() + + base_app.editor = "'fooedit'" + file_name = utils.quote_string("'is_single_quoted.py'") + out, err = run_cmd(base_app, "edit {}".format(file_name)) + shell_mock.assert_called_once_with('"\'fooedit\'" "\'is_single_quoted.py\'"') + shell_mock.reset_mock() + 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 base_app.editor = 'fooedit' @@ -995,7 +1042,7 @@ class SelectApp(cmd2.Cmd): def do_procrastinate(self, arg): """Waste time in your manner of choice.""" # Pass in a list of tuples for selections - leisure_activity = self.select([('Netflix and chill', 'Netflix'), ('Porn', 'WebSurfing')], + leisure_activity = self.select([('Netflix and chill', 'Netflix'), ('YouTube', 'WebSurfing')], 'How would you like to procrastinate? ') result = 'Have fun procrasinating with {}!\n'.format(leisure_activity) self.stdout.write(result) @@ -1107,7 +1154,7 @@ def test_select_list_of_tuples(select_app): 1. Netflix 2. WebSurfing Have fun procrasinating with {}! -""".format('Porn')) +""".format('YouTube')) # Make sure our mock was called with the expected arguments m.assert_called_once_with('How would you like to procrastinate? ') @@ -1672,9 +1719,12 @@ def test_macro_create_with_alias_name(base_app): out, err = run_cmd(base_app, 'macro create {} help'.format(macro)) assert "Macro cannot have the same name as an alias" in err[0] -def test_macro_create_with_command_name(base_app): - out, err = run_cmd(base_app, 'macro create help stuff') - assert "Macro cannot have the same name as a command" in err[0] +def test_macro_create_with_command_name(multiline_app): + out, err = run_cmd(multiline_app, 'macro create help stuff') + assert out == normalize("Macro 'help' created") + + out, err = run_cmd(multiline_app, 'macro create orate stuff') + assert "Macro cannot have the same name as a multiline command" in err[0] def test_macro_create_with_args(base_app): # Create the macro @@ -1793,6 +1843,36 @@ def test_nonexistent_macro(base_app): assert exception is not None +def test_input_line_to_statement_expand(base_app): + # Enable/Disable expansion of shortcuts + line = '!ls' + statement = base_app._input_line_to_statement(line, expand=True) + assert statement.command == 'shell' + + statement = base_app._input_line_to_statement(line, expand=False) + assert statement.command == '!ls' + + # Enable/Disable expansion of aliases + run_cmd(base_app, 'alias create help macro') + + line = 'help' + statement = base_app._input_line_to_statement(line, expand=True) + assert statement.command == 'macro' + + statement = base_app._input_line_to_statement(line, expand=False) + assert statement.command == 'help' + + run_cmd(base_app, 'alias delete help') + + # Enable/Disable expansion of macros + run_cmd(base_app, 'macro create help alias') + + line = 'help' + statement = base_app._input_line_to_statement(line, expand=True) + assert statement.command == 'alias' + + statement = base_app._input_line_to_statement(line, expand=False) + assert statement.command == 'help' def test_ppaged(outsim_app): msg = 'testing...' @@ -2077,16 +2157,19 @@ class DisableCommandsApp(cmd2.Cmd): super().__init__(*args, **kwargs) @cmd2.with_category(category_name) - def do_has_help_func(self, arg): - self.poutput("The real has_help_func") + def do_has_helper_funcs(self, arg): + self.poutput("The real has_helper_funcs") + + def help_has_helper_funcs(self): + self.poutput('Help for has_helper_funcs') - def help_has_help_func(self): - self.poutput('Help for has_help_func') + def complete_has_helper_funcs(self, *args): + return ['result'] @cmd2.with_category(category_name) - def do_has_no_help_func(self, arg): - """Help for has_no_help_func""" - self.poutput("The real has_no_help_func") + def do_has_no_helper_funcs(self, arg): + """Help for has_no_helper_funcs""" + self.poutput("The real has_no_helper_funcs") @pytest.fixture @@ -2096,51 +2179,92 @@ def disable_commands_app(): def test_disable_and_enable_category(disable_commands_app): + ########################################################################## # Disable the category + ########################################################################## message_to_print = 'These commands are currently disabled' disable_commands_app.disable_category(disable_commands_app.category_name, message_to_print) # Make sure all the commands and help on those commands displays the message - out, err = run_cmd(disable_commands_app, 'has_help_func') + out, err = run_cmd(disable_commands_app, 'has_helper_funcs') assert err[0].startswith(message_to_print) - out, err = run_cmd(disable_commands_app, 'help has_help_func') + out, err = run_cmd(disable_commands_app, 'help has_helper_funcs') assert err[0].startswith(message_to_print) - out, err = run_cmd(disable_commands_app, 'has_no_help_func') + out, err = run_cmd(disable_commands_app, 'has_no_helper_funcs') assert err[0].startswith(message_to_print) - out, err = run_cmd(disable_commands_app, 'help has_no_help_func') + out, err = run_cmd(disable_commands_app, 'help has_no_helper_funcs') assert err[0].startswith(message_to_print) + # Make sure neither function completes + text = '' + line = 'has_helper_funcs' + endidx = len(line) + begidx = endidx - len(text) + + first_match = complete_tester(text, line, begidx, endidx, disable_commands_app) + assert first_match is None + + text = '' + line = 'has_no_helper_funcs' + endidx = len(line) + begidx = endidx - len(text) + + first_match = complete_tester(text, line, begidx, endidx, disable_commands_app) + assert first_match is None + + # Make sure both commands are invisible visible_commands = disable_commands_app.get_visible_commands() - assert 'has_help_func' not in visible_commands - assert 'has_no_help_func' not in visible_commands + assert 'has_helper_funcs' not in visible_commands + assert 'has_no_helper_funcs' not in visible_commands + ########################################################################## # Enable the category + ########################################################################## disable_commands_app.enable_category(disable_commands_app.category_name) # Make sure all the commands and help on those commands are restored - out, err = run_cmd(disable_commands_app, 'has_help_func') - assert out[0] == "The real has_help_func" + out, err = run_cmd(disable_commands_app, 'has_helper_funcs') + assert out[0] == "The real has_helper_funcs" + + out, err = run_cmd(disable_commands_app, 'help has_helper_funcs') + assert out[0] == "Help for has_helper_funcs" + + out, err = run_cmd(disable_commands_app, 'has_no_helper_funcs') + assert out[0] == "The real has_no_helper_funcs" + + out, err = run_cmd(disable_commands_app, 'help has_no_helper_funcs') + assert out[0] == "Help for has_no_helper_funcs" + + # has_helper_funcs should complete now + text = '' + line = 'has_helper_funcs' + endidx = len(line) + begidx = endidx - len(text) - out, err = run_cmd(disable_commands_app, 'help has_help_func') - assert out[0] == "Help for has_help_func" + first_match = complete_tester(text, line, begidx, endidx, disable_commands_app) + assert first_match is not None and disable_commands_app.completion_matches == ['result '] - out, err = run_cmd(disable_commands_app, 'has_no_help_func') - assert out[0] == "The real has_no_help_func" + # has_no_helper_funcs had no completer originally, so there should be no results + text = '' + line = 'has_no_helper_funcs' + endidx = len(line) + begidx = endidx - len(text) - out, err = run_cmd(disable_commands_app, 'help has_no_help_func') - assert out[0] == "Help for has_no_help_func" + first_match = complete_tester(text, line, begidx, endidx, disable_commands_app) + assert first_match is None + # Make sure both commands are visible visible_commands = disable_commands_app.get_visible_commands() - assert 'has_help_func' in visible_commands - assert 'has_no_help_func' in visible_commands + assert 'has_helper_funcs' in visible_commands + assert 'has_no_helper_funcs' in visible_commands def test_enable_enabled_command(disable_commands_app): # Test enabling a command that is not disabled saved_len = len(disable_commands_app.disabled_commands) - disable_commands_app.enable_command('has_help_func') + disable_commands_app.enable_command('has_helper_funcs') # The number of disabled_commands should not have changed assert saved_len == len(disable_commands_app.disabled_commands) @@ -2152,7 +2276,7 @@ def test_disable_fake_command(disable_commands_app): def test_disable_command_twice(disable_commands_app): saved_len = len(disable_commands_app.disabled_commands) message_to_print = 'These commands are currently disabled' - disable_commands_app.disable_command('has_help_func', message_to_print) + disable_commands_app.disable_command('has_helper_funcs', message_to_print) # The length of disabled_commands should have increased one new_len = len(disable_commands_app.disabled_commands) @@ -2160,24 +2284,24 @@ def test_disable_command_twice(disable_commands_app): saved_len = new_len # Disable again and the length should not change - disable_commands_app.disable_command('has_help_func', message_to_print) + disable_commands_app.disable_command('has_helper_funcs', message_to_print) new_len = len(disable_commands_app.disabled_commands) assert saved_len == new_len def test_disabled_command_not_in_history(disable_commands_app): message_to_print = 'These commands are currently disabled' - disable_commands_app.disable_command('has_help_func', message_to_print) + disable_commands_app.disable_command('has_helper_funcs', message_to_print) saved_len = len(disable_commands_app.history) - run_cmd(disable_commands_app, 'has_help_func') + run_cmd(disable_commands_app, 'has_helper_funcs') assert saved_len == len(disable_commands_app.history) def test_disabled_message_command_name(disable_commands_app): message_to_print = '{} is currently disabled'.format(cmd2.cmd2.COMMAND_NAME) - disable_commands_app.disable_command('has_help_func', message_to_print) + disable_commands_app.disable_command('has_helper_funcs', message_to_print) - out, err = run_cmd(disable_commands_app, 'has_help_func') - assert err[0].startswith('has_help_func is currently disabled') + out, err = run_cmd(disable_commands_app, 'has_helper_funcs') + assert err[0].startswith('has_helper_funcs is currently disabled') def test_startup_script(request): diff --git a/tests/test_history.py b/tests/test_history.py index 88f38172..2da34cfd 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -477,16 +477,21 @@ def test_history_edit(base_app, monkeypatch): # going to call it due to the mock base_app.editor = 'fooedit' - # Mock out the Popen call so we don't actually open an editor - m = mock.MagicMock(name='Popen') - monkeypatch.setattr("subprocess.Popen", m) + # Mock out the edit call so we don't actually open an editor + edit_mock = mock.MagicMock(name='do_edit') + monkeypatch.setattr("cmd2.Cmd.do_edit", edit_mock) + + # Mock out the run_script call since the mocked edit won't produce a file + run_script_mock = mock.MagicMock(name='do_run_script') + monkeypatch.setattr("cmd2.Cmd.do_run_script", run_script_mock) # Run help command just so we have a command in history run_cmd(base_app, 'help') run_cmd(base_app, 'history -e 1') - # We have an editor, so should expect a Popen call - m.assert_called_once() + # Make sure both functions were called + edit_mock.assert_called_once() + run_script_mock.assert_called_once() def test_history_run_all_commands(base_app): # make sure we refuse to run all commands as a default diff --git a/tests/test_run_pyscript.py b/tests/test_run_pyscript.py index ded95225..a4ff097f 100644 --- a/tests/test_run_pyscript.py +++ b/tests/test_run_pyscript.py @@ -6,7 +6,7 @@ Unit/functional testing for run_pytest in cmd2 import builtins import os -from cmd2 import plugin +from cmd2 import plugin, utils from .conftest import run_cmd # Python 3.5 had some regressions in the unitest.mock module, so use 3rd party mock if available @@ -32,7 +32,7 @@ def test_run_pyscript(base_app, request): def test_run_pyscript_recursive_not_allowed(base_app, request): test_dir = os.path.dirname(request.module.__file__) - python_script = os.path.join(test_dir, 'scripts', 'recursive.py') + python_script = os.path.join(test_dir, 'pyscript', 'recursive.py') expected = 'Recursively entering interactive Python consoles is not allowed.' out, err = run_cmd(base_app, "run_pyscript {}".format(python_script)) @@ -41,7 +41,7 @@ def test_run_pyscript_recursive_not_allowed(base_app, request): def test_run_pyscript_with_nonexist_file(base_app): python_script = 'does_not_exist.py' out, err = run_cmd(base_app, "run_pyscript {}".format(python_script)) - assert "Error opening script file" in err[0] + assert "Error reading script file" in err[0] def test_run_pyscript_with_non_python_file(base_app, request): m = mock.MagicMock(name='input', return_value='2') @@ -52,9 +52,34 @@ def test_run_pyscript_with_non_python_file(base_app, request): out, err = run_cmd(base_app, 'run_pyscript {}'.format(filename)) assert "does not have a .py extension" in err[0] +def test_run_pyscript_with_odd_file_names(base_app): + """ + Pass in file names with various patterns. Since these files don't exist, we will rely + on the error text to make sure the file names were processed correctly. + """ + python_script = utils.quote_string('nothingweird.py') + out, err = run_cmd(base_app, "run_pyscript {}".format(python_script)) + assert "Error reading script file 'nothingweird.py'" in err[0] + + python_script = utils.quote_string('has spaces.py') + out, err = run_cmd(base_app, "run_pyscript {}".format(python_script)) + assert "Error reading script file 'has spaces.py'" in err[0] + + # For remaining tests, mock input to get us passed the warning about not ending in .py + input_mock = mock.MagicMock(name='input', return_value='1') + builtins.input = input_mock + + python_script = utils.quote_string('"is_double_quoted.py"') + out, err = run_cmd(base_app, "run_pyscript {}".format(python_script)) + assert "Error reading script file '\"is_double_quoted.py\"'" in err[1] + + python_script = utils.quote_string("'is_single_quoted.py'") + out, err = run_cmd(base_app, "run_pyscript {}".format(python_script)) + assert "Error reading script file ''is_single_quoted.py''" in err[1] + def test_run_pyscript_with_exception(base_app, request): test_dir = os.path.dirname(request.module.__file__) - python_script = os.path.join(test_dir, 'scripts', 'raises_exception.py') + python_script = os.path.join(test_dir, 'pyscript', 'raises_exception.py') out, err = run_cmd(base_app, "run_pyscript {}".format(python_script)) assert err[0].startswith('Traceback') assert "TypeError: unsupported operand type(s) for +: 'int' and 'str'" in err[-1] @@ -91,7 +116,7 @@ def test_run_pyscript_stop(base_app, request): # Verify onecmd_plus_hooks() returns True if any commands in a pyscript return True for stop test_dir = os.path.dirname(request.module.__file__) - # help.py doesn't run any commands that returns True for stop + # help.py doesn't run any commands that return True for stop python_script = os.path.join(test_dir, 'pyscript', 'help.py') stop = base_app.onecmd_plus_hooks('run_pyscript {}'.format(python_script)) assert not stop @@ -100,3 +125,11 @@ def test_run_pyscript_stop(base_app, request): python_script = os.path.join(test_dir, 'pyscript', 'stop.py') stop = base_app.onecmd_plus_hooks('run_pyscript {}'.format(python_script)) assert stop + +def test_run_pyscript_run(base_app, request): + test_dir = os.path.dirname(request.module.__file__) + python_script = os.path.join(test_dir, 'pyscript', 'run.py') + expected = 'I have been run' + + out, err = run_cmd(base_app, "run_pyscript {}".format(python_script)) + assert expected in out diff --git a/tests/test_utils.py b/tests/test_utils.py index edb6ca68..1890a753 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -93,13 +93,23 @@ def test_is_quoted_no(): simple_str = "hello world" assert not cu.is_quoted(simple_str) +def test_quote_string(): + my_str = "Hello World" + assert cu.quote_string(my_str) == '"' + my_str + '"' + + my_str = "'Hello World'" + assert cu.quote_string(my_str) == '"' + my_str + '"' + + my_str = '"Hello World"' + assert cu.quote_string(my_str) == "'" + my_str + "'" + def test_quote_string_if_needed_yes(): my_str = "Hello World" assert cu.quote_string_if_needed(my_str) == '"' + my_str + '"' your_str = '"foo" bar' assert cu.quote_string_if_needed(your_str) == "'" + your_str + "'" -def test_quot_string_if_needed_no(): +def test_quote_string_if_needed_no(): my_str = "HelloWorld" assert cu.quote_string_if_needed(my_str) == my_str your_str = "'Hello World'" |