From 9156618a56d635bb51261d019a3703a1b4e3b588 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 14 Feb 2020 16:28:41 -0500 Subject: Fixed bug where pyscripts could edit cmd2.Cmd.py_locals dictionary. Fixed bug where cmd2 set sys.path[0] for a pyscript to its cwd instead of the script's directory. Fixed bug where sys.path was not being restored after a pyscript ran. Setting the following pyscript variables: __name__: __main__ __file__: script path (as typed) Removed do_py.run() function since it didn't handle arguments and offered no benefit over run_pyscript. --- CHANGELOG.md | 7 ++++ cmd2/cmd2.py | 92 ++++++++++++++++++++++--------------------- tests/pyscript/environment.py | 20 ++++++++++ tests/pyscript/recursive.py | 1 + tests/pyscript/run.py | 6 --- tests/pyscript/to_run.py | 2 - tests/test_cmd2.py | 15 ++++--- tests/test_run_pyscript.py | 9 ++--- 8 files changed, 88 insertions(+), 64 deletions(-) create mode 100644 tests/pyscript/environment.py delete mode 100644 tests/pyscript/run.py delete mode 100644 tests/pyscript/to_run.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f9d71831..edf5f93f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,15 @@ * Corrected issue where the actual new value was not always being printed in do_set. This occurred in cases where the typed value differed from what the setter had converted it to. * Fixed bug where ANSI style sequences were not correctly handled in `utils.truncate_line()`. + * Fixed bug where pyscripts could edit `cmd2.Cmd.py_locals` dictionary. + * Fixed bug where cmd2 set sys.path[0] for a pyscript to cmd2's working directory instead of the + script file's directory. + * Fixed bug where sys.path was not being restored after a pyscript ran. * Enhancements * Renamed set command's `-l/--long` flag to `-v/--verbose` for consistency with help and history commands. + * Setting the following pyscript variables: + * `__name__`: __main__ + * `__file__`: script path (as typed) ## 0.10.0 (February 7, 2020) * Enhancements diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 8f2cdca3..c7a0fa6d 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3093,8 +3093,7 @@ class Cmd(cmd.Cmd): # 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. + # other arguments. py_parser.add_argument('--pyscript', help=argparse.SUPPRESS) # Preserve quotes since we are passing these strings to Python @@ -3104,65 +3103,66 @@ class Cmd(cmd.Cmd): Enter an interactive Python shell :return: True if running of commands should stop """ + def py_quit(): + """Function callable from the interactive Python console to exit that environment""" + raise EmbeddedConsoleExit + from .py_bridge import PyBridge + py_bridge = PyBridge(self) + saved_sys_path = None + if self.in_pyscript(): err = "Recursively entering interactive Python consoles is not allowed." self.perror(err) 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 + py_code_to_run = '' - def py_run(filename: str): - """Run a Python script file in the interactive console. - :param filename: filename of script file to run - """ - expanded_filename = os.path.expanduser(filename) + # Locals for the Python environment we are creating + localvars = dict(self.py_locals) + localvars[self.py_bridge_name] = py_bridge + localvars['quit'] = py_quit + localvars['exit'] = py_quit + + if self.self_in_py: + localvars['self'] = self + + # Handle case where we were called by run_pyscript + if args.pyscript: + # Read the script file + expanded_filename = os.path.expanduser(utils.strip_quotes(args.pyscript)) try: with open(expanded_filename) as f: - interp.runcode(f.read()) + py_code_to_run = f.read() except OSError as ex: self.pexcept("Error reading script file '{}': {}".format(expanded_filename, ex)) + return - def py_quit(): - """Function callable from the interactive Python console to exit that environment""" - raise EmbeddedConsoleExit + localvars['__name__'] = '__main__' + localvars['__file__'] = expanded_filename - # Set up Python environment - self.py_locals[self.py_bridge_name] = py_bridge - self.py_locals['run'] = py_run - self.py_locals['quit'] = py_quit - self.py_locals['exit'] = py_quit + # Place the script's directory at sys.path[0] just as Python does when executing a script + saved_sys_path = list(sys.path) + sys.path.insert(0, os.path.dirname(os.path.abspath(expanded_filename))) - if self.self_in_py: - self.py_locals['self'] = self - elif 'self' in self.py_locals: - del self.py_locals['self'] + else: + # This is the default name chosen by InteractiveConsole when no locals are passed in + localvars['__name__'] = '__console__' + + if args.command: + py_code_to_run = args.command + if args.remainder: + py_code_to_run += ' ' + ' '.join(args.remainder) - localvars = self.py_locals + # 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 + + # Create the Python interpreter interp = InteractiveConsole(locals=localvars) - interp.runcode('import sys, os;sys.path.insert(0, os.getcwd())') # Check if we are running Python code if py_code_to_run: @@ -3177,8 +3177,7 @@ class Cmd(cmd.Cmd): else: cprt = 'Type "help", "copyright", "credits" or "license" for more information.' 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")' + 'Non-Python commands can be issued with: {}("your command")' .format(self.py_bridge_name)) saved_cmd2_env = None @@ -3205,7 +3204,10 @@ class Cmd(cmd.Cmd): pass finally: - self._in_py = False + with self.sigint_protection: + if saved_sys_path is not None: + sys.path = saved_sys_path + self._in_py = False return py_bridge.stop diff --git a/tests/pyscript/environment.py b/tests/pyscript/environment.py new file mode 100644 index 00000000..5e4d93d6 --- /dev/null +++ b/tests/pyscript/environment.py @@ -0,0 +1,20 @@ +# flake8: noqa F821 +# Tests that cmd2 populates __name__, __file__, and sets sys.path[0] to our directory +import os +import sys +app.cmd_echo = True + +if __name__ != '__main__': + print("Error: __name__ is: {}".format(__name__)) + quit() + +if __file__ != sys.argv[0]: + print("Error: __file__ is: {}".format(__file__)) + quit() + +our_dir = os.path.dirname(os.path.abspath(__file__)) +if our_dir != sys.path[0]: + print("Error: our_dir is: {}".format(our_dir)) + quit() + +print("PASSED") diff --git a/tests/pyscript/recursive.py b/tests/pyscript/recursive.py index 21550592..7f02bb78 100644 --- a/tests/pyscript/recursive.py +++ b/tests/pyscript/recursive.py @@ -5,6 +5,7 @@ Example demonstrating that calling run_pyscript recursively inside another Python script isn't allowed """ import os +import sys app.cmd_echo = True my_dir = (os.path.dirname(os.path.realpath(sys.argv[0]))) diff --git a/tests/pyscript/run.py b/tests/pyscript/run.py deleted file mode 100644 index 47250a10..00000000 --- a/tests/pyscript/run.py +++ /dev/null @@ -1,6 +0,0 @@ -# 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 deleted file mode 100644 index b207952d..00000000 --- a/tests/pyscript/to_run.py +++ /dev/null @@ -1,2 +0,0 @@ -# flake8: noqa F821 -print("I have been run") diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 376658e5..41528612 100755 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -205,14 +205,17 @@ def test_base_shell(base_app, monkeypatch): 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') - assert not out + # Make sure py can't edit Cmd.py_locals. It used to be that cmd2 was passing its py_locals + # dictionary to the py environment instead of a copy. + base_app.py_locals['test_var'] = 5 + out, err = run_cmd(base_app, 'py del[locals()["test_var"]]') + assert not out and not err + assert base_app.py_locals['test_var'] == 5 - out, err = run_cmd(base_app, 'py print(qqq)') - assert out[0].rstrip() == '3' + out, err = run_cmd(base_app, 'py print(test_var)') + assert out[0].rstrip() == '5' - # Add a more complex statement + # Try a print statement out, err = run_cmd(base_app, 'py print("spaces" + " in this " + "command")') assert out[0].rstrip() == 'spaces in this command' diff --git a/tests/test_run_pyscript.py b/tests/test_run_pyscript.py index d717758c..811fd688 100644 --- a/tests/test_run_pyscript.py +++ b/tests/test_run_pyscript.py @@ -117,10 +117,9 @@ def test_run_pyscript_stop(base_app, request): stop = base_app.onecmd_plus_hooks('run_pyscript {}'.format(python_script)) assert stop -def test_run_pyscript_run(base_app, request): +def test_run_pyscript_environment(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' + python_script = os.path.join(test_dir, 'pyscript', 'environment.py') + out, err = run_cmd(base_app, 'run_pyscript {}'.format(python_script)) - out, err = run_cmd(base_app, "run_pyscript {}".format(python_script)) - assert expected in out + assert out[0] == "PASSED" -- cgit v1.2.1 From db9c3a6d455e2bb19be7ae295c73a28751a7b481 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 14 Feb 2020 17:09:24 -0500 Subject: Updated change log --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edf5f93f..18ac2348 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ * Renamed set command's `-l/--long` flag to `-v/--verbose` for consistency with help and history commands. * Setting the following pyscript variables: * `__name__`: __main__ - * `__file__`: script path (as typed) + * `__file__`: script path (as typed, ~ will be expanded) ## 0.10.0 (February 7, 2020) * Enhancements -- cgit v1.2.1 From 555db5d98fc122dc21d3bb239c079d68272cc0c3 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 17 Feb 2020 11:24:28 -0500 Subject: Updated documentation and tests --- CHANGELOG.md | 2 ++ cmd2/cmd2.py | 4 +++- tests/test_cmd2.py | 9 ++++++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18ac2348..4d662cf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ * Setting the following pyscript variables: * `__name__`: __main__ * `__file__`: script path (as typed, ~ will be expanded) +* Other + * Removed undocumented `py run` command since it was replaced by `run_pyscript` a while ago ## 0.10.0 (February 7, 2020) * Enhancements diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index c7a0fa6d..3b1fbb6f 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3120,7 +3120,9 @@ class Cmd(cmd.Cmd): self._in_py = True py_code_to_run = '' - # Locals for the Python environment we are creating + # Use self.py_locals as the locals() dictionary in the Python environment we are creating, but make + # a copy to prevent pyscripts from editing it. (e.g. locals().clear()). Only make a shallow copy since + # it's OK for py_locals to contain objects which are editable in a pyscript. localvars = dict(self.py_locals) localvars[self.py_bridge_name] = py_bridge localvars['quit'] = py_quit diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 41528612..dc9a3fa1 100755 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -206,7 +206,7 @@ def test_base_shell(base_app, monkeypatch): def test_base_py(base_app): # Make sure py can't edit Cmd.py_locals. It used to be that cmd2 was passing its py_locals - # dictionary to the py environment instead of a copy. + # dictionary to the py environment instead of a shallow copy. base_app.py_locals['test_var'] = 5 out, err = run_cmd(base_app, 'py del[locals()["test_var"]]') assert not out and not err @@ -215,6 +215,13 @@ def test_base_py(base_app): out, err = run_cmd(base_app, 'py print(test_var)') assert out[0].rstrip() == '5' + # Place an editable object in py_locals. Since we make a shallow copy of py_locals, + # this object should be editable from the py environment. + base_app.py_locals['my_list'] = [] + out, err = run_cmd(base_app, 'py my_list.append(2)') + assert not out and not err + assert base_app.py_locals['my_list'][0] == 2 + # Try a print statement out, err = run_cmd(base_app, 'py print("spaces" + " in this " + "command")') assert out[0].rstrip() == 'spaces in this command' -- cgit v1.2.1 From b6971fb9d518da673de1943e9e47dffe1acaf4b4 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 17 Feb 2020 12:12:55 -0500 Subject: Only tab complete after redirection tokens if redirection is allowed --- CHANGELOG.md | 1 + cmd2/cmd2.py | 4 ++-- tests/test_completion.py | 60 ++++++++++++++++++++++++++---------------------- 3 files changed, 35 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d662cf3..cb328f11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ * Setting the following pyscript variables: * `__name__`: __main__ * `__file__`: script path (as typed, ~ will be expanded) + * Only tab complete after redirection tokens if redirection is allowed * Other * Removed undocumented `py run` command since it was replaced by `run_pyscript` a while ago diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 3b1fbb6f..5a728e56 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1050,8 +1050,8 @@ class Cmd(cmd.Cmd): in_pipe = False in_file_redir = True - # Not a redirection token - else: + # Only tab complete after redirection tokens if redirection is allowed + elif self.allow_redirection: do_shell_completion = False do_path_completion = False diff --git a/tests/test_completion.py b/tests/test_completion.py index f545c8f9..e13d87de 100755 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -902,11 +902,6 @@ class RedirCompType(enum.Enum): DEFAULT = 3, NONE = 4 - """ - fake > > - fake | grep > file - fake | grep > file > - """ @pytest.mark.parametrize('line, comp_type', [ ('fake', RedirCompType.DEFAULT), @@ -933,31 +928,40 @@ class RedirCompType(enum.Enum): ('fake > file >>', RedirCompType.NONE), ]) def test_redirect_complete(cmd2_app, monkeypatch, line, comp_type): - shell_cmd_complete_mock = mock.MagicMock(name='shell_cmd_complete') - monkeypatch.setattr("cmd2.Cmd.shell_cmd_complete", shell_cmd_complete_mock) - - path_complete_mock = mock.MagicMock(name='path_complete') - monkeypatch.setattr("cmd2.Cmd.path_complete", path_complete_mock) - - default_complete_mock = mock.MagicMock(name='fake_completer') - - text = '' - line = '{} {}'.format(line, text) - endidx = len(line) - begidx = endidx - len(text) + # Test both cases of allow_redirection + cmd2_app.allow_redirection = True + for count in range(2): + shell_cmd_complete_mock = mock.MagicMock(name='shell_cmd_complete') + monkeypatch.setattr("cmd2.Cmd.shell_cmd_complete", shell_cmd_complete_mock) + + path_complete_mock = mock.MagicMock(name='path_complete') + monkeypatch.setattr("cmd2.Cmd.path_complete", path_complete_mock) + + default_complete_mock = mock.MagicMock(name='fake_completer') + + text = '' + line = '{} {}'.format(line, text) + endidx = len(line) + begidx = endidx - len(text) + + cmd2_app._redirect_complete(text, line, begidx, endidx, default_complete_mock) + + if comp_type == RedirCompType.SHELL_CMD: + shell_cmd_complete_mock.assert_called_once() + elif comp_type == RedirCompType.PATH: + path_complete_mock.assert_called_once() + elif comp_type == RedirCompType.DEFAULT: + default_complete_mock.assert_called_once() + else: + shell_cmd_complete_mock.assert_not_called() + path_complete_mock.assert_not_called() + default_complete_mock.assert_not_called() - cmd2_app._redirect_complete(text, line, begidx, endidx, default_complete_mock) + # Do the next test with allow_redirection as False + cmd2_app.allow_redirection = False + if comp_type != RedirCompType.DEFAULT: + comp_type = RedirCompType.NONE - if comp_type == RedirCompType.SHELL_CMD: - shell_cmd_complete_mock.assert_called_once() - elif comp_type == RedirCompType.PATH: - path_complete_mock.assert_called_once() - elif comp_type == RedirCompType.DEFAULT: - default_complete_mock.assert_called_once() - else: - shell_cmd_complete_mock.assert_not_called() - path_complete_mock.assert_not_called() - default_complete_mock.assert_not_called() def test_complete_set_value(cmd2_app): text = '' -- cgit v1.2.1 From 878601bc07e5298d50fbf1bd6a8fc2062fef5ed4 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 17 Feb 2020 12:45:20 -0500 Subject: Renamed AutoCompleter to ArgparseCompleter for clarity --- CHANGELOG.md | 1 + cmd2/argparse_completer.py | 30 +++++++++++++++--------------- cmd2/argparse_custom.py | 24 ++++++++++++------------ cmd2/cmd2.py | 22 +++++++++++----------- cmd2/utils.py | 4 ++-- tests/test_argparse_completer.py | 24 ++++++++++++------------ 6 files changed, 53 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb328f11..dcd2ef42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ * Only tab complete after redirection tokens if redirection is allowed * Other * Removed undocumented `py run` command since it was replaced by `run_pyscript` a while ago + * Renamed `AutoCompleter` to `ArgparseCompleter` for clarity ## 0.10.0 (February 7, 2020) * Enhancements diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 185e01a2..eae2ae28 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -2,7 +2,7 @@ # flake8: noqa C901 # NOTE: Ignoring flake8 cyclomatic complexity in this file """ -This module defines the AutoCompleter class which provides argparse-based tab completion to cmd2 apps. +This module defines the ArgparseCompleter class which provides argparse-based tab completion to cmd2 apps. See the header of argparse_custom.py for instructions on how to use these features. """ @@ -64,7 +64,7 @@ def _looks_like_flag(token: str, parser: argparse.ArgumentParser) -> bool: # noinspection PyProtectedMember -class AutoCompleter: +class ArgparseCompleter: """Automatic command line tab completion based on argparse parameters""" class _ArgumentState: @@ -103,12 +103,12 @@ class AutoCompleter: def __init__(self, parser: argparse.ArgumentParser, cmd2_app: cmd2.Cmd, *, parent_tokens: Optional[Dict[str, List[str]]] = None) -> None: """ - Create an AutoCompleter + Create an ArgparseCompleter :param parser: ArgumentParser instance - :param cmd2_app: reference to the Cmd2 application that owns this AutoCompleter + :param cmd2_app: reference to the Cmd2 application that owns this ArgparseCompleter :param parent_tokens: optional dictionary mapping parent parsers' arg names to their tokens - this is only used by AutoCompleter when recursing on subcommand parsers + This is only used by ArgparseCompleter when recursing on subcommand parsers Defaults to None """ self._parser = parser @@ -167,7 +167,7 @@ class AutoCompleter: # Completed mutually exclusive groups completed_mutex_groups = dict() # dict(argparse._MutuallyExclusiveGroup -> Action which completed group) - def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None: + def consume_argument(arg_state: ArgparseCompleter._ArgumentState) -> None: """Consuming token as an argument""" arg_state.count += 1 consumed_arg_values.setdefault(arg_state.action.dest, []) @@ -286,7 +286,7 @@ class AutoCompleter: # earlier in the command line. Reset them now for this use of it. consumed_arg_values[action.dest] = [] - new_arg_state = AutoCompleter._ArgumentState(action) + new_arg_state = ArgparseCompleter._ArgumentState(action) # Keep track of this flag if it can receive arguments if new_arg_state.max > 0: @@ -319,8 +319,8 @@ class AutoCompleter: if action.dest != argparse.SUPPRESS: parent_tokens[action.dest] = [token] - completer = AutoCompleter(self._subcommand_action.choices[token], self._cmd2_app, - parent_tokens=parent_tokens) + completer = ArgparseCompleter(self._subcommand_action.choices[token], self._cmd2_app, + parent_tokens=parent_tokens) return completer.complete_command(tokens[token_index:], text, line, begidx, endidx) else: # Invalid subcommand entered, so no way to complete remaining tokens @@ -328,7 +328,7 @@ class AutoCompleter: # Otherwise keep track of the argument else: - pos_arg_state = AutoCompleter._ArgumentState(action) + pos_arg_state = ArgparseCompleter._ArgumentState(action) # Check if we have a positional to consume this token if pos_arg_state is not None: @@ -393,7 +393,7 @@ class AutoCompleter: # If we aren't current tracking a positional, then get the next positional arg to handle this token if pos_arg_state is None: action = remaining_positionals.popleft() - pos_arg_state = AutoCompleter._ArgumentState(action) + pos_arg_state = ArgparseCompleter._ArgumentState(action) try: completion_results = self._complete_for_arg(pos_arg_state.action, text, line, @@ -483,11 +483,11 @@ class AutoCompleter: :return: List of subcommand completions """ # If our parser has subcommands, we must examine the tokens and check if they are subcommands - # If so, we will let the subcommand's parser handle the rest of the tokens via another AutoCompleter. + # If so, we will let the subcommand's parser handle the rest of the tokens via another ArgparseCompleter. if self._subcommand_action is not None: for token_index, token in enumerate(tokens[1:], start=1): if token in self._subcommand_action.choices: - completer = AutoCompleter(self._subcommand_action.choices[token], self._cmd2_app) + completer = ArgparseCompleter(self._subcommand_action.choices[token], self._cmd2_app) return completer.complete_subcommand_help(tokens[token_index:], text, line, begidx, endidx) elif token_index == len(tokens) - 1: # Since this is the last token, we will attempt to complete it @@ -503,11 +503,11 @@ class AutoCompleter: :return: help text of the command being queried """ # If our parser has subcommands, we must examine the tokens and check if they are subcommands - # If so, we will let the subcommand's parser handle the rest of the tokens via another AutoCompleter. + # If so, we will let the subcommand's parser handle the rest of the tokens via another ArgparseCompleter. if self._subcommand_action is not None: for token_index, token in enumerate(tokens[1:], start=1): if token in self._subcommand_action.choices: - completer = AutoCompleter(self._subcommand_action.choices[token], self._cmd2_app) + completer = ArgparseCompleter(self._subcommand_action.choices[token], self._cmd2_app) return completer.format_help(tokens[token_index:]) else: break diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index a59270c3..fd1ea057 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -26,7 +26,7 @@ value with no upper bound, use a 1-item tuple (min,) parser.add_argument('-f', nargs=(3, 5)) Tab Completion: - cmd2 uses its AutoCompleter class to enable argparse-based tab completion on all commands that use the + cmd2 uses its ArgparseCompleter class to enable argparse-based tab completion on all commands that use the @with_argparse wrappers. Out of the box you get tab completion of commands, subcommands, and flag names, as well as instructive hints about the current argument that print when tab is pressed. In addition, you can add tab completion for each argument's values using parameters passed to add_argument(). @@ -53,7 +53,7 @@ Tab Completion: choices_method This is exactly like choices_function, but the function needs to be an instance method of a cmd2-based class. - When AutoCompleter calls the method, it will pass the app instance as the self argument. This is good in + When ArgparseCompleter calls the method, it will pass the app instance as the self argument. This is good in cases where the list of choices being generated relies on state data of the cmd2-based app Example: @@ -74,7 +74,7 @@ Tab Completion: completer_method This is exactly like completer_function, but the function needs to be an instance method of a cmd2-based class. - When AutoCompleter calls the method, it will pass the app instance as the self argument. cmd2 provides + When ArgparseCompleter calls the method, it will pass the app instance as the self argument. cmd2 provides a few completer methods for convenience (e.g., path_complete, delimiter_complete) Example: @@ -113,12 +113,12 @@ Tab Completion: def my_completer_method(self, text, line, begidx, endidx, arg_tokens) All values of the arg_tokens dictionary are lists, even if a particular argument expects only 1 token. Since - AutoCompleter is for tab completion, it does not convert the tokens to their actual argument types or validate + ArgparseCompleter is for tab completion, it does not convert the tokens to their actual argument types or validate their values. All tokens are stored in the dictionary as the raw strings provided on the command line. It is up to the developer to determine if the user entered the correct argument type (e.g. int) and validate their values. CompletionError Class: - Raised during tab completion operations to report any sort of error you want printed by the AutoCompleter + Raised during tab completion operations to report any sort of error you want printed by the ArgparseCompleter Example use cases - Reading a database to retrieve a tab completion data set failed @@ -127,7 +127,7 @@ CompletionError Class: CompletionItem Class: This class was added to help in cases where uninformative data is being tab completed. For instance, tab completing ID numbers isn't very helpful to a user without context. Returning a list of CompletionItems - instead of a regular string for completion results will signal the AutoCompleter to output the completion + instead of a regular string for completion results will signal the ArgparseCompleter to output the completion results in a table of completion tokens with descriptions instead of just a table of tokens. Instead of this: @@ -231,7 +231,7 @@ def generate_range_error(range_min: int, range_max: Union[int, float]) -> str: class CompletionError(Exception): """ - Raised during tab completion operations to report any sort of error you want printed by the AutoCompleter + Raised during tab completion operations to report any sort of error you want printed by the ArgparseCompleter Example use cases - Reading a database to retrieve a tab completion data set failed @@ -353,15 +353,15 @@ def _add_argument_wrapper(self, *args, :param nargs: extends argparse nargs functionality by allowing tuples which specify a range (min, max) to specify a max value with no upper bound, use a 1-item tuple (min,) - # Added args used by AutoCompleter + # Added args used by ArgparseCompleter :param choices_function: function that provides choices for this argument :param choices_method: cmd2-app method that provides choices for this argument :param completer_function: tab completion function that provides choices for this argument :param completer_method: cmd2-app tab completion method that provides choices for this argument - :param suppress_tab_hint: when AutoCompleter has no results to show during tab completion, it displays the current - argument's help text as a hint. Set this to True to suppress the hint. If this argument's - help text is set to argparse.SUPPRESS, then tab hints will not display regardless of the - value passed for suppress_tab_hint. Defaults to False. + :param suppress_tab_hint: when ArgparseCompleter has no results to show during tab completion, it displays the + current argument's help text as a hint. Set this to True to suppress the hint. If this + argument's help text is set to argparse.SUPPRESS, then tab hints will not display + regardless of the value passed for suppress_tab_hint. Defaults to False. :param descriptive_header: if the provided choices are CompletionItems, then this header will display during tab completion. Defaults to None. diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 5a728e56..9e1085b2 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1425,8 +1425,8 @@ class Cmd(cmd.Cmd): def _autocomplete_default(self, text: str, line: str, begidx: int, endidx: int, *, argparser: argparse.ArgumentParser, preserve_quotes: bool) -> List[str]: """Default completion function for argparse commands""" - from .argparse_completer import AutoCompleter - completer = AutoCompleter(argparser, self) + from .argparse_completer import ArgparseCompleter + completer = ArgparseCompleter(argparser, self) tokens, raw_tokens = self.tokens_for_completion(line, begidx, endidx) # To have tab-completion parsing match command line parsing behavior, @@ -2560,11 +2560,11 @@ class Cmd(cmd.Cmd): if func is None or argparser is None: return [] - # Combine the command and its subcommand tokens for the AutoCompleter + # Combine the command and its subcommand tokens for the ArgparseCompleter tokens = [command] + arg_tokens['subcommands'] - from .argparse_completer import AutoCompleter - completer = AutoCompleter(argparser, self) + from .argparse_completer import ArgparseCompleter + completer = ArgparseCompleter(argparser, self) return completer.complete_subcommand_help(tokens, text, line, begidx, endidx) help_parser = DEFAULT_ARGUMENT_PARSER(description="List available commands or provide " @@ -2576,7 +2576,7 @@ class Cmd(cmd.Cmd): help_parser.add_argument('-v', '--verbose', action='store_true', help="print a list of all commands with descriptions of each") - # Get rid of cmd's complete_help() functions so AutoCompleter will complete the help command + # Get rid of cmd's complete_help() functions so ArgparseCompleter will complete the help command if getattr(cmd.Cmd, 'complete_help', None) is not None: delattr(cmd.Cmd, 'complete_help') @@ -2594,8 +2594,8 @@ class Cmd(cmd.Cmd): # If the command function uses argparse, then use argparse's help if func is not None and argparser is not None: - from .argparse_completer import AutoCompleter - completer = AutoCompleter(argparser, self) + from .argparse_completer import ArgparseCompleter + completer = ArgparseCompleter(argparser, self) tokens = [args.command] + args.subcommands # Set end to blank so the help output matches how it looks when "command -h" is used @@ -2838,8 +2838,8 @@ class Cmd(cmd.Cmd): completer_function=settable.completer_function, completer_method=settable.completer_method) - from .argparse_completer import AutoCompleter - completer = AutoCompleter(settable_parser, self) + from .argparse_completer import ArgparseCompleter + completer = ArgparseCompleter(settable_parser, self) # Use raw_tokens since quotes have been preserved _, raw_tokens = self.tokens_for_completion(line, begidx, endidx) @@ -2860,7 +2860,7 @@ class Cmd(cmd.Cmd): set_parser = DEFAULT_ARGUMENT_PARSER(parents=[set_parser_parent]) # Suppress tab-completion hints for this field. The completer method is going to create an - # AutoCompleter based on the actual parameter being completed and we only want that hint printing. + # ArgparseCompleter based on the actual parameter being completed and we only want that hint printing. set_parser.add_argument('value', nargs=argparse.OPTIONAL, help='new value for settable', completer_method=complete_set_value, suppress_tab_hint=True) diff --git a/cmd2/utils.py b/cmd2/utils.py index e324c2f1..b307e0d2 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -109,8 +109,8 @@ class Settable: for this argument (See note below) Note: - For choices_method and completer_method, do not set them to a bound method. This is because AutoCompleter - passes the self argument explicitly to these functions. + For choices_method and completer_method, do not set them to a bound method. This is because + ArgparseCompleter passes the self argument explicitly to these functions. Therefore instead of passing something like self.path_complete, pass cmd2.Cmd.path_complete. """ diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 97c75ef3..0eb3892f 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -42,20 +42,20 @@ def completer_function(text: str, line: str, begidx: int, endidx: int) -> List[s def choices_takes_arg_tokens(arg_tokens: argparse.Namespace) -> List[str]: - """Choices function that receives arg_tokens from AutoCompleter""" + """Choices function that receives arg_tokens from ArgparseCompleter""" return [arg_tokens['parent_arg'][0], arg_tokens['subcommand'][0]] def completer_takes_arg_tokens(text: str, line: str, begidx: int, endidx: int, arg_tokens: argparse.Namespace) -> List[str]: - """Completer function that receives arg_tokens from AutoCompleter""" + """Completer function that receives arg_tokens from ArgparseCompleter""" match_against = [arg_tokens['parent_arg'][0], arg_tokens['subcommand'][0]] return basic_complete(text, line, begidx, endidx, match_against) # noinspection PyMethodMayBeStatic,PyUnusedLocal class AutoCompleteTester(cmd2.Cmd): - """Cmd2 app that exercises AutoCompleter class""" + """Cmd2 app that exercises ArgparseCompleter class""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -421,7 +421,7 @@ def test_autocomp_flag_choices_completion(ac_app, flag, text, completions): else: assert first_match is None - # Numbers will be sorted in ascending order and then converted to strings by AutoCompleter + # Numbers will be sorted in ascending order and then converted to strings by ArgparseCompleter if all(isinstance(x, numbers.Number) for x in completions): completions.sort() completions = [str(x) for x in completions] @@ -496,8 +496,8 @@ def test_autocomp_positional_completers(ac_app, pos, text, completions): def test_autocomp_blank_token(ac_app): - """Force a blank token to make sure AutoCompleter consumes them like argparse does""" - from cmd2.argparse_completer import AutoCompleter + """Force a blank token to make sure ArgparseCompleter consumes them like argparse does""" + from cmd2.argparse_completer import ArgparseCompleter blank = '' @@ -507,7 +507,7 @@ def test_autocomp_blank_token(ac_app): endidx = len(line) begidx = endidx - len(text) - completer = AutoCompleter(ac_app.completer_parser, ac_app) + completer = ArgparseCompleter(ac_app.completer_parser, ac_app) tokens = ['completer', '-f', blank, text] completions = completer.complete_command(tokens, text, line, begidx, endidx) assert completions == completions_from_function @@ -518,7 +518,7 @@ def test_autocomp_blank_token(ac_app): endidx = len(line) begidx = endidx - len(text) - completer = AutoCompleter(ac_app.completer_parser, ac_app) + completer = ArgparseCompleter(ac_app.completer_parser, ac_app) tokens = ['completer', blank, text] completions = completer.complete_command(tokens, text, line, begidx, endidx) assert completions == completions_from_method @@ -867,20 +867,20 @@ def test_looks_like_flag(): def test_complete_command_no_tokens(ac_app): - from cmd2.argparse_completer import AutoCompleter + from cmd2.argparse_completer import ArgparseCompleter parser = Cmd2ArgumentParser() - ac = AutoCompleter(parser, ac_app) + ac = ArgparseCompleter(parser, ac_app) completions = ac.complete_command(tokens=[], text='', line='', begidx=0, endidx=0) assert not completions def test_complete_command_help_no_tokens(ac_app): - from cmd2.argparse_completer import AutoCompleter + from cmd2.argparse_completer import ArgparseCompleter parser = Cmd2ArgumentParser() - ac = AutoCompleter(parser, ac_app) + ac = ArgparseCompleter(parser, ac_app) completions = ac.complete_subcommand_help(tokens=[], text='', line='', begidx=0, endidx=0) assert not completions -- cgit v1.2.1 From d80b27f8e259ffe3b14cef88e8ed9cde350ba397 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 17 Feb 2020 14:13:28 -0500 Subject: Made CompletionError exception available to non-argparse tab completion --- CHANGELOG.md | 1 + cmd2/__init__.py | 4 +- cmd2/argparse_completer.py | 236 +++++++++++++++++++-------------------- cmd2/argparse_custom.py | 18 --- cmd2/cmd2.py | 10 +- cmd2/utils.py | 11 ++ examples/argparse_completion.py | 4 +- tests/test_argparse_completer.py | 4 +- 8 files changed, 139 insertions(+), 149 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcd2ef42..dca2d32d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ * `__name__`: __main__ * `__file__`: script path (as typed, ~ will be expanded) * Only tab complete after redirection tokens if redirection is allowed + * Made `CompletionError` exception available to non-argparse tab completion * Other * Removed undocumented `py run` command since it was replaced by `run_pyscript` a while ago * Renamed `AutoCompleter` to `ArgparseCompleter` for clarity diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 43578e46..73d70821 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -11,7 +11,7 @@ except DistributionNotFound: pass from .ansi import style, fg, bg -from .argparse_custom import Cmd2ArgumentParser, CompletionError, CompletionItem, set_default_argument_parser +from .argparse_custom import Cmd2ArgumentParser, CompletionItem, set_default_argument_parser # Check if user has defined a module that sets a custom value for argparse_custom.DEFAULT_ARGUMENT_PARSER import argparse @@ -27,4 +27,4 @@ from .constants import COMMAND_NAME, DEFAULT_SHORTCUTS from .decorators import categorize, with_argument_list, with_argparser, with_argparser_and_unknown_args, with_category from .parsing import Statement from .py_bridge import CommandResult -from .utils import Settable +from .utils import CompletionError, Settable diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index eae2ae28..a0c19959 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -16,11 +16,10 @@ from typing import Dict, List, Optional, Union from . import ansi from . import cmd2 -from . import utils from .argparse_custom import ATTR_CHOICES_CALLABLE, INFINITY, generate_range_error from .argparse_custom import ATTR_SUPPRESS_TAB_HINT, ATTR_DESCRIPTIVE_COMPLETION_HEADER, ATTR_NARGS_RANGE -from .argparse_custom import ChoicesCallable, CompletionError, CompletionItem -from .rl_utils import rl_force_redisplay +from .argparse_custom import ChoicesCallable, CompletionItem +from .utils import basic_complete, CompletionError # If no descriptive header is supplied, then this will be used instead DEFAULT_DESCRIPTIVE_HEADER = 'Description' @@ -63,43 +62,96 @@ def _looks_like_flag(token: str, parser: argparse.ArgumentParser) -> bool: return True +class _ArgumentState: + """Keeps state of an argument being parsed""" + def __init__(self, arg_action: argparse.Action) -> None: + self.action = arg_action + self.min = None + self.max = None + self.count = 0 + self.is_remainder = (self.action.nargs == argparse.REMAINDER) + + # Check if nargs is a range + nargs_range = getattr(self.action, ATTR_NARGS_RANGE, None) + if nargs_range is not None: + self.min = nargs_range[0] + self.max = nargs_range[1] + + # Otherwise check against argparse types + elif self.action.nargs is None: + self.min = 1 + self.max = 1 + elif self.action.nargs == argparse.OPTIONAL: + self.min = 0 + self.max = 1 + elif self.action.nargs == argparse.ZERO_OR_MORE or self.action.nargs == argparse.REMAINDER: + self.min = 0 + self.max = INFINITY + elif self.action.nargs == argparse.ONE_OR_MORE: + self.min = 1 + self.max = INFINITY + else: + self.min = self.action.nargs + self.max = self.action.nargs + + # noinspection PyProtectedMember -class ArgparseCompleter: - """Automatic command line tab completion based on argparse parameters""" +class _ActionCompletionError(CompletionError): + def __init__(self, arg_action: argparse.Action, completion_error: CompletionError) -> None: + """ + Adds action-specific information to a CompletionError. These are raised when + non-argparse related errors occur during tab completion. + :param arg_action: action being tab completed + :param completion_error: error that occurred + """ + # Indent all lines of completion_error + indented_error = textwrap.indent(str(completion_error), ' ') - class _ArgumentState: - """Keeps state of an argument being parsed""" - - def __init__(self, arg_action: argparse.Action) -> None: - self.action = arg_action - self.min = None - self.max = None - self.count = 0 - self.is_remainder = (self.action.nargs == argparse.REMAINDER) - - # Check if nargs is a range - nargs_range = getattr(self.action, ATTR_NARGS_RANGE, None) - if nargs_range is not None: - self.min = nargs_range[0] - self.max = nargs_range[1] - - # Otherwise check against argparse types - elif self.action.nargs is None: - self.min = 1 - self.max = 1 - elif self.action.nargs == argparse.OPTIONAL: - self.min = 0 - self.max = 1 - elif self.action.nargs == argparse.ZERO_OR_MORE or self.action.nargs == argparse.REMAINDER: - self.min = 0 - self.max = INFINITY - elif self.action.nargs == argparse.ONE_OR_MORE: - self.min = 1 - self.max = INFINITY - else: - self.min = self.action.nargs - self.max = self.action.nargs + error = ("\nError tab completing {}:\n" + "{}\n".format(argparse._get_action_name(arg_action), indented_error)) + super().__init__(ansi.style_error(error)) + + +# noinspection PyProtectedMember +class _UnfinishedFlagError(CompletionError): + def __init__(self, flag_arg_state: _ArgumentState) -> None: + """ + CompletionError which occurs when the user has not finished the current flag + :param flag_arg_state: information about the unfinished flag action + """ + error = "\nError: argument {}: {} ({} entered)\n".\ + format(argparse._get_action_name(flag_arg_state.action), + generate_range_error(flag_arg_state.min, flag_arg_state.max), + flag_arg_state.count) + super().__init__(ansi.style_error(error)) + + +# noinspection PyProtectedMember +class _NoResultsError(CompletionError): + def __init__(self, parser: argparse.ArgumentParser, arg_action: argparse.Action) -> None: + """ + CompletionError which occurs when there are no results. If hinting is allowed, then its message will + be a hint about the argument being tab completed. + :param parser: ArgumentParser instance which owns the action being tab completed + :param arg_action: action being tab completed + """ + # Check if hinting is disabled + suppress_hint = getattr(arg_action, ATTR_SUPPRESS_TAB_HINT, False) + if suppress_hint or arg_action.help == argparse.SUPPRESS: + hint_str = '' + else: + # Use the parser's help formatter to print just this action's help text + formatter = parser._get_formatter() + formatter.start_section("Hint") + formatter.add_argument(arg_action) + formatter.end_section() + hint_str = '\n' + formatter.format_help() + super().__init__(hint_str) + +# noinspection PyProtectedMember +class ArgparseCompleter: + """Automatic command line tab completion based on argparse parameters""" def __init__(self, parser: argparse.ArgumentParser, cmd2_app: cmd2.Cmd, *, parent_tokens: Optional[Dict[str, List[str]]] = None) -> None: """ @@ -141,7 +193,10 @@ class ArgparseCompleter: self._subcommand_action = action def complete_command(self, tokens: List[str], text: str, line: str, begidx: int, endidx: int) -> List[str]: - """Complete the command using the argparse metadata and provided argument dictionary""" + """ + Complete the command using the argparse metadata and provided argument dictionary + :raises: CompletionError for various types of tab completion errors + """ if not tokens: return [] @@ -167,18 +222,18 @@ class ArgparseCompleter: # Completed mutually exclusive groups completed_mutex_groups = dict() # dict(argparse._MutuallyExclusiveGroup -> Action which completed group) - def consume_argument(arg_state: ArgparseCompleter._ArgumentState) -> None: + def consume_argument(arg_state: _ArgumentState) -> None: """Consuming token as an argument""" arg_state.count += 1 consumed_arg_values.setdefault(arg_state.action.dest, []) consumed_arg_values[arg_state.action.dest].append(token) - def update_mutex_groups(arg_action: argparse.Action) -> bool: + def update_mutex_groups(arg_action: argparse.Action) -> None: """ Check if an argument belongs to a mutually exclusive group and either mark that group as complete or print an error if the group has already been completed :param arg_action: the action of the argument - :return: False if the group has already been completed and there is a conflict, otherwise True + :raises: CompletionError if the group is already completed """ # Check if this action is in a mutually exclusive group for group in self._parser._mutually_exclusive_groups: @@ -191,13 +246,12 @@ class ArgparseCompleter: # since it's allowed to appear on the command line more than once. completer_action = completed_mutex_groups[group] if arg_action == completer_action: - return True + return error = ansi.style_error("\nError: argument {}: not allowed with argument {}\n". format(argparse._get_action_name(arg_action), argparse._get_action_name(completer_action))) - self._print_message(error) - return False + raise CompletionError(error) # Mark that this action completed the group completed_mutex_groups[group] = arg_action @@ -214,8 +268,6 @@ class ArgparseCompleter: # Arg can only be in one group, so we are done break - return True - ############################################################################################# # Parse all but the last token ############################################################################################# @@ -238,8 +290,7 @@ class ArgparseCompleter: elif token == '--' and not skip_remaining_flags: # Check if there is an unfinished flag if flag_arg_state is not None and flag_arg_state.count < flag_arg_state.min: - self._print_unfinished_flag_error(flag_arg_state) - return [] + raise _UnfinishedFlagError(flag_arg_state) # Otherwise end the current flag else: @@ -252,8 +303,7 @@ class ArgparseCompleter: # Check if there is an unfinished flag if flag_arg_state is not None and flag_arg_state.count < flag_arg_state.min: - self._print_unfinished_flag_error(flag_arg_state) - return [] + raise _UnfinishedFlagError(flag_arg_state) # Reset flag arg state but not positional tracking because flags can be # interspersed anywhere between positionals @@ -269,9 +319,7 @@ class ArgparseCompleter: action = self._flag_to_action[candidates_flags[0]] if action is not None: - if not update_mutex_groups(action): - return [] - + update_mutex_groups(action) if isinstance(action, (argparse._AppendAction, argparse._AppendConstAction, argparse._CountAction)): @@ -286,7 +334,7 @@ class ArgparseCompleter: # earlier in the command line. Reset them now for this use of it. consumed_arg_values[action.dest] = [] - new_arg_state = ArgparseCompleter._ArgumentState(action) + new_arg_state = _ArgumentState(action) # Keep track of this flag if it can receive arguments if new_arg_state.max > 0: @@ -328,14 +376,11 @@ class ArgparseCompleter: # Otherwise keep track of the argument else: - pos_arg_state = ArgparseCompleter._ArgumentState(action) + pos_arg_state = _ArgumentState(action) # Check if we have a positional to consume this token if pos_arg_state is not None: - # No need to check for an error since we remove a completed group's positional from - # remaining_positionals which means this action can't belong to a completed mutex group update_mutex_groups(pos_arg_state.action) - consume_argument(pos_arg_state) # No more flags are allowed if this is a REMAINDER argument @@ -361,9 +406,7 @@ class ArgparseCompleter: # character (-f) at the end. if _looks_like_flag(text, self._parser) and not skip_remaining_flags: if flag_arg_state is not None and flag_arg_state.count < flag_arg_state.min: - self._print_unfinished_flag_error(flag_arg_state) - return [] - + raise _UnfinishedFlagError(flag_arg_state) return self._complete_flags(text, line, begidx, endidx, matched_flags) completion_results = [] @@ -374,8 +417,7 @@ class ArgparseCompleter: completion_results = self._complete_for_arg(flag_arg_state.action, text, line, begidx, endidx, consumed_arg_values) except CompletionError as ex: - self._print_completion_error(flag_arg_state.action, ex) - return [] + raise _ActionCompletionError(flag_arg_state.action, ex) # If we have results, then return them if completion_results: @@ -384,8 +426,7 @@ class ArgparseCompleter: # Otherwise, print a hint if the flag isn't finished or text isn't possibly the start of a flag elif flag_arg_state.count < flag_arg_state.min or \ not _single_prefix_char(text, self._parser) or skip_remaining_flags: - self._print_arg_hint(flag_arg_state.action) - return [] + raise _NoResultsError(self._parser, flag_arg_state.action) # Otherwise check if we have a positional to complete elif pos_arg_state is not None or remaining_positionals: @@ -393,14 +434,13 @@ class ArgparseCompleter: # If we aren't current tracking a positional, then get the next positional arg to handle this token if pos_arg_state is None: action = remaining_positionals.popleft() - pos_arg_state = ArgparseCompleter._ArgumentState(action) + pos_arg_state = _ArgumentState(action) try: completion_results = self._complete_for_arg(pos_arg_state.action, text, line, begidx, endidx, consumed_arg_values) except CompletionError as ex: - self._print_completion_error(pos_arg_state.action, ex) - return [] + raise _ActionCompletionError(pos_arg_state.action, ex) # If we have results, then return them if completion_results: @@ -408,8 +448,7 @@ class ArgparseCompleter: # Otherwise, print a hint if text isn't possibly the start of a flag elif not _single_prefix_char(text, self._parser) or skip_remaining_flags: - self._print_arg_hint(pos_arg_state.action) - return [] + raise _NoResultsError(self._parser, pos_arg_state.action) # Handle case in which text is a single flag prefix character that # didn't complete against any argument values. @@ -432,7 +471,7 @@ class ArgparseCompleter: if action.help != argparse.SUPPRESS: match_against.append(flag) - return utils.basic_complete(text, line, begidx, endidx, match_against) + return basic_complete(text, line, begidx, endidx, match_against) def _format_completions(self, action, completions: List[Union[str, CompletionItem]]) -> List[str]: # Check if the results are CompletionItems and that there aren't too many to display @@ -491,7 +530,7 @@ class ArgparseCompleter: return completer.complete_subcommand_help(tokens[token_index:], text, line, begidx, endidx) elif token_index == len(tokens) - 1: # Since this is the last token, we will attempt to complete it - return utils.basic_complete(text, line, begidx, endidx, self._subcommand_action.choices) + return basic_complete(text, line, begidx, endidx, self._subcommand_action.choices) else: break return [] @@ -519,7 +558,7 @@ class ArgparseCompleter: """ Tab completion routine for an argparse argument :return: list of completions - :raises CompletionError if the completer or choices function this calls raises one + :raises: CompletionError if the completer or choices function this calls raises one """ # Check if the arg provides choices to the user if arg_action.choices is not None: @@ -579,55 +618,6 @@ class ArgparseCompleter: arg_choices = [choice for choice in arg_choices if choice not in used_values] # Do tab completion on the choices - results = utils.basic_complete(text, line, begidx, endidx, arg_choices) + results = basic_complete(text, line, begidx, endidx, arg_choices) return self._format_completions(arg_action, results) - - @staticmethod - def _print_message(msg: str) -> None: - """Print a message instead of tab completions and redraw the prompt and input line""" - import sys - ansi.style_aware_write(sys.stdout, msg + '\n') - rl_force_redisplay() - - def _print_arg_hint(self, arg_action: argparse.Action) -> None: - """ - Print argument hint to the terminal when tab completion results in no results - :param arg_action: action being tab completed - """ - # Check if hinting is disabled - suppress_hint = getattr(arg_action, ATTR_SUPPRESS_TAB_HINT, False) - if suppress_hint or arg_action.help == argparse.SUPPRESS: - return - - # Use the parser's help formatter to print just this action's help text - formatter = self._parser._get_formatter() - formatter.start_section("Hint") - formatter.add_argument(arg_action) - formatter.end_section() - out_str = formatter.format_help() - self._print_message('\n' + out_str) - - def _print_unfinished_flag_error(self, flag_arg_state: _ArgumentState) -> None: - """ - Print an error during tab completion when the user has not finished the current flag - :param flag_arg_state: information about the unfinished flag action - """ - error = "\nError: argument {}: {} ({} entered)\n".\ - format(argparse._get_action_name(flag_arg_state.action), - generate_range_error(flag_arg_state.min, flag_arg_state.max), - flag_arg_state.count) - self._print_message(ansi.style_error('{}'.format(error))) - - def _print_completion_error(self, arg_action: argparse.Action, completion_error: CompletionError) -> None: - """ - Print a CompletionError to the user - :param arg_action: action being tab completed - :param completion_error: error that occurred - """ - # Indent all lines of completion_error - indented_error = textwrap.indent(str(completion_error), ' ') - - error = ("\nError tab completing {}:\n" - "{}\n".format(argparse._get_action_name(arg_action), indented_error)) - self._print_message(ansi.style_error('{}'.format(error))) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index fd1ea057..81fec013 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -117,13 +117,6 @@ Tab Completion: their values. All tokens are stored in the dictionary as the raw strings provided on the command line. It is up to the developer to determine if the user entered the correct argument type (e.g. int) and validate their values. -CompletionError Class: - Raised during tab completion operations to report any sort of error you want printed by the ArgparseCompleter - - Example use cases - - Reading a database to retrieve a tab completion data set failed - - A previous command line argument that determines the data set being completed is invalid - CompletionItem Class: This class was added to help in cases where uninformative data is being tab completed. For instance, tab completing ID numbers isn't very helpful to a user without context. Returning a list of CompletionItems @@ -229,17 +222,6 @@ def generate_range_error(range_min: int, range_max: Union[int, float]) -> str: return err_str -class CompletionError(Exception): - """ - Raised during tab completion operations to report any sort of error you want printed by the ArgparseCompleter - - Example use cases - - Reading a database to retrieve a tab completion data set failed - - A previous command line argument that determines the data set being completed is invalid - """ - pass - - class CompletionItem(str): """ Completion item with descriptive text attached diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 9e1085b2..f4e1ef8d 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -47,13 +47,13 @@ from . import ansi from . import constants from . import plugin from . import utils -from .argparse_custom import CompletionError, CompletionItem, DEFAULT_ARGUMENT_PARSER +from .argparse_custom import CompletionItem, DEFAULT_ARGUMENT_PARSER from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer from .decorators import with_argparser from .history import History, HistoryItem from .parsing import StatementParser, Statement, Macro, MacroArg, shlex_split from .rl_utils import rl_type, RlType, rl_get_point, rl_set_prompt, vt100_support, rl_make_safe_prompt, rl_warning -from .utils import Settable +from .utils import CompletionError, Settable # Set up readline if rl_type == RlType.NONE: # pragma: no cover @@ -1416,6 +1416,12 @@ class Cmd(cmd.Cmd): except IndexError: return None + except CompletionError as e: + err_str = str(e) + if err_str: + ansi.style_aware_write(sys.stdout, err_str + '\n') + rl_force_redisplay() + return None except Exception as e: # Insert a newline so the exception doesn't print in the middle of the command line being tab completed self.perror() diff --git a/cmd2/utils.py b/cmd2/utils.py index b307e0d2..b6b45891 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -72,6 +72,17 @@ def str_to_bool(val: str) -> bool: raise ValueError("must be True or False (case-insensitive)") +class CompletionError(Exception): + """ + Raised during tab completion operations to report any sort of error you want printed by the ArgparseCompleter + + Example use cases + - Reading a database to retrieve a tab completion data set failed + - A previous command line argument that determines the data set being completed is invalid + """ + pass + + class Settable: """Used to configure a cmd2 instance member to be settable via the set command in the CLI""" def __init__(self, name: str, val_type: Callable, description: str, *, diff --git a/examples/argparse_completion.py b/examples/argparse_completion.py index 90975d3f..bf2b2723 100644 --- a/examples/argparse_completion.py +++ b/examples/argparse_completion.py @@ -6,8 +6,8 @@ A simple example demonstrating how to integrate tab completion with argparse-bas import argparse from typing import Dict, List -from cmd2 import Cmd, Cmd2ArgumentParser, with_argparser, CompletionError, CompletionItem -from cmd2.utils import basic_complete +from cmd2 import Cmd, Cmd2ArgumentParser, with_argparser, CompletionItem +from cmd2.utils import basic_complete, CompletionError # Data source for argparse.choices food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato'] diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 0eb3892f..c0cb8b4a 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -9,8 +9,8 @@ from typing import List import pytest import cmd2 -from cmd2 import with_argparser, Cmd2ArgumentParser, CompletionError, CompletionItem -from cmd2.utils import StdSim, basic_complete +from cmd2 import with_argparser, Cmd2ArgumentParser, CompletionItem +from cmd2.utils import CompletionError, StdSim, basic_complete from .conftest import run_cmd, complete_tester # Lists used in our tests (there is a mix of sorted and unsorted on purpose) -- cgit v1.2.1 From e1dc7637ab99bedaafa421b7fd499ed6302008f1 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 17 Feb 2020 14:25:32 -0500 Subject: Updated unit test --- cmd2/cmd2.py | 1 + tests/test_argparse_completer.py | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index f4e1ef8d..49273b51 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1419,6 +1419,7 @@ class Cmd(cmd.Cmd): except CompletionError as e: err_str = str(e) if err_str: + # Don't print error and redraw the prompt unless the error has length ansi.style_aware_write(sys.stdout, err_str + '\n') rl_force_redisplay() return None diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index c0cb8b4a..2619d053 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -699,7 +699,7 @@ def test_completion_items_default_header(ac_app): ('hint', '', True), ('hint --flag', '', True), ('hint --suppressed_help', '', False), - ('hint --suppressed_hint', '--', False), + ('hint --suppressed_hint', '', False), # Hint because flag does not have enough values to be considered finished ('nargs --one_or_more', '-', True), @@ -730,7 +730,10 @@ def test_autocomp_hint(ac_app, command_and_args, text, has_hint, capsys): complete_tester(text, line, begidx, endidx, ac_app) out, err = capsys.readouterr() - assert has_hint == ("Hint:\n" in out) + if has_hint: + assert "Hint:\n" in out + else: + assert not out def test_autocomp_hint_no_help_text(ac_app, capsys): -- cgit v1.2.1 From d214709eecf2208b5edb6c52af69a0d76973e595 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 18 Feb 2020 12:10:34 -0500 Subject: Fixed issue where argparse completion errors were being rewrapped as _ActionCompletionError in some cases --- cmd2/argparse_completer.py | 23 +++++++++++++++------- cmd2/cmd2.py | 2 +- tests/test_argparse_completer.py | 42 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index a0c19959..add8868c 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -95,8 +95,13 @@ class _ArgumentState: self.max = self.action.nargs +class _ArgparseCompletionError(CompletionError): + """CompletionError specific to argparse-based tab completion""" + pass + + # noinspection PyProtectedMember -class _ActionCompletionError(CompletionError): +class _ActionCompletionError(_ArgparseCompletionError): def __init__(self, arg_action: argparse.Action, completion_error: CompletionError) -> None: """ Adds action-specific information to a CompletionError. These are raised when @@ -107,19 +112,19 @@ class _ActionCompletionError(CompletionError): # Indent all lines of completion_error indented_error = textwrap.indent(str(completion_error), ' ') - error = ("\nError tab completing {}:\n" - "{}\n".format(argparse._get_action_name(arg_action), indented_error)) + error = ("Error tab completing {}:\n" + "{}".format(argparse._get_action_name(arg_action), indented_error)) super().__init__(ansi.style_error(error)) # noinspection PyProtectedMember -class _UnfinishedFlagError(CompletionError): +class _UnfinishedFlagError(_ArgparseCompletionError): def __init__(self, flag_arg_state: _ArgumentState) -> None: """ CompletionError which occurs when the user has not finished the current flag :param flag_arg_state: information about the unfinished flag action """ - error = "\nError: argument {}: {} ({} entered)\n".\ + error = "Error: argument {}: {} ({} entered)".\ format(argparse._get_action_name(flag_arg_state.action), generate_range_error(flag_arg_state.min, flag_arg_state.max), flag_arg_state.count) @@ -127,7 +132,7 @@ class _UnfinishedFlagError(CompletionError): # noinspection PyProtectedMember -class _NoResultsError(CompletionError): +class _NoResultsError(_ArgparseCompletionError): def __init__(self, parser: argparse.ArgumentParser, arg_action: argparse.Action) -> None: """ CompletionError which occurs when there are no results. If hinting is allowed, then its message will @@ -145,7 +150,7 @@ class _NoResultsError(CompletionError): formatter.start_section("Hint") formatter.add_argument(arg_action) formatter.end_section() - hint_str = '\n' + formatter.format_help() + hint_str = formatter.format_help() super().__init__(hint_str) @@ -416,6 +421,8 @@ class ArgparseCompleter: try: completion_results = self._complete_for_arg(flag_arg_state.action, text, line, begidx, endidx, consumed_arg_values) + except _ArgparseCompletionError as ex: + raise ex except CompletionError as ex: raise _ActionCompletionError(flag_arg_state.action, ex) @@ -439,6 +446,8 @@ class ArgparseCompleter: try: completion_results = self._complete_for_arg(pos_arg_state.action, text, line, begidx, endidx, consumed_arg_values) + except _ArgparseCompletionError as ex: + raise ex except CompletionError as ex: raise _ActionCompletionError(pos_arg_state.action, ex) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 49273b51..60d5463a 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1420,7 +1420,7 @@ class Cmd(cmd.Cmd): err_str = str(e) if err_str: # Don't print error and redraw the prompt unless the error has length - ansi.style_aware_write(sys.stdout, err_str + '\n') + ansi.style_aware_write(sys.stdout, '\n' + err_str + '\n') rl_force_redisplay() return None except Exception as e: diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 2619d053..83cee30f 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -30,6 +30,8 @@ positional_choices = ['the', 'positional', 'choices'] completions_from_function = ['completions', 'function', 'fairly', 'complete'] completions_from_method = ['completions', 'method', 'missed', 'spot'] +AP_COMP_ERROR_TEXT = "SHOULD ONLY BE THIS TEXT" + def choices_function() -> List[str]: """Function that provides choices""" @@ -53,7 +55,7 @@ def completer_takes_arg_tokens(text: str, line: str, begidx: int, endidx: int, return basic_complete(text, line, begidx, endidx, match_against) -# noinspection PyMethodMayBeStatic,PyUnusedLocal +# noinspection PyMethodMayBeStatic,PyUnusedLocal,PyProtectedMember class AutoCompleteTester(cmd2.Cmd): """Cmd2 app that exercises ArgparseCompleter class""" def __init__(self, *args, **kwargs): @@ -181,6 +183,7 @@ class AutoCompleteTester(cmd2.Cmd): choices=one_or_more_choices) nargs_parser.add_argument("--optional", help="a flag with an optional value", nargs=argparse.OPTIONAL, choices=optional_choices) + # noinspection PyTypeChecker nargs_parser.add_argument("--range", help="a flag with nargs range", nargs=(1, 2), choices=range_choices) nargs_parser.add_argument("--remainder", help="a flag wanting remaining", nargs=argparse.REMAINDER, @@ -231,6 +234,24 @@ class AutoCompleteTester(cmd2.Cmd): def do_raise_completion_error(self, args: argparse.Namespace) -> None: pass + ############################################################################################################ + # Begin code related to _ArgparseCompletionError + ############################################################################################################ + def raise_argparse_completion_error(self): + """Raises ArgparseCompletionError to make sure it gets raised as is""" + from cmd2.argparse_completer import _ArgparseCompletionError + raise _ArgparseCompletionError(AP_COMP_ERROR_TEXT) + + ap_comp_error_parser = Cmd2ArgumentParser() + ap_comp_error_parser.add_argument('pos_ap_comp_err', help='pos ap completion error', + choices_method=raise_argparse_completion_error) + ap_comp_error_parser.add_argument('--flag_ap_comp_err', help='flag ap completion error', + choices_method=raise_argparse_completion_error) + + @with_argparser(ap_comp_error_parser) + def do_raise_ap_completion_error(self, args: argparse.Namespace) -> None: + pass + ############################################################################################################ # Begin code related to receiving arg_tokens ############################################################################################################ @@ -772,6 +793,25 @@ def test_completion_error(ac_app, capsys, args, text): assert "{} broke something".format(text) in out +@pytest.mark.parametrize('arg', [ + # Exercise positional arg that raises _ArgparseCompletionError + '', + + # Exercise flag arg that raises _ArgparseCompletionError + '--flag_ap_comp_err' +]) +def test_argparse_completion_error(ac_app, capsys, arg): + text = '' + line = 'raise_ap_completion_error {} {}'.format(arg, text) + endidx = len(line) + begidx = endidx - len(text) + + first_match = complete_tester(text, line, begidx, endidx, ac_app) + assert first_match is None + out, err = capsys.readouterr() + assert out.strip() == AP_COMP_ERROR_TEXT + + @pytest.mark.parametrize('command_and_args, completions', [ # Exercise a choices function that receives arg_tokens dictionary ('arg_tokens choice subcmd', ['choice', 'subcmd']), -- cgit v1.2.1 From 065536a484bb705e1e6b7971fc4c8efdb637185e Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 18 Feb 2020 12:18:32 -0500 Subject: Added use of CompletionError to basic completion example --- examples/basic_completion.py | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/examples/basic_completion.py b/examples/basic_completion.py index e021828b..b043e157 100755 --- a/examples/basic_completion.py +++ b/examples/basic_completion.py @@ -2,18 +2,21 @@ # coding=utf-8 """ A simple example demonstrating how to enable tab completion by assigning a completer function to do_* commands. -This also demonstrates capabilities of the following completer methods included with cmd2: -- delimiter_completer -- flag_based_complete (see note below) -- index_based_complete (see note below) +This also demonstrates capabilities of the following completer features included with cmd2: +- CompletionError exceptions +- delimiter_completer() +- flag_based_complete() (see note below) +- index_based_complete() (see note below) flag_based_complete() and index_based_complete() are basic methods and should only be used if you are not familiar with argparse. The recommended approach for tab completing positional tokens and flags is to use argparse-based completion. For an example integrating tab completion with argparse, see argparse_completion.py """ import functools +from typing import List import cmd2 +from cmd2 import ansi # List of strings used with completion functions food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato'] @@ -42,7 +45,7 @@ class BasicCompletion(cmd2.Cmd): """ self.poutput("Args: {}".format(statement.args)) - def complete_flag_based(self, text, line, begidx, endidx): + def complete_flag_based(self, text, line, begidx, endidx) -> List[str]: """Completion function for do_flag_based""" flag_dict = \ { @@ -65,7 +68,7 @@ class BasicCompletion(cmd2.Cmd): """Tab completes first 3 arguments using index_based_complete""" self.poutput("Args: {}".format(statement.args)) - def complete_index_based(self, text, line, begidx, endidx): + def complete_index_based(self, text, line, begidx, endidx) -> List[str]: """Completion function for do_index_based""" index_dict = \ { @@ -84,6 +87,20 @@ class BasicCompletion(cmd2.Cmd): complete_delimiter_complete = functools.partialmethod(cmd2.Cmd.delimiter_complete, match_against=file_strs, delimiter='/') + def do_raise_error(self, statement: cmd2.Statement): + """Demonstrates effect of raising CompletionError""" + self.poutput("Args: {}".format(statement.args)) + + def complete_raise_error(self, text, line, begidx, endidx) -> List[str]: + """ + CompletionErrors can be raised if an error occurs while tab completing. + + Example use cases + - Reading a database to retrieve a tab completion data set failed + - A previous command line argument that determines the data set being completed is invalid + """ + raise cmd2.CompletionError(ansi.style_error("This is how a CompletionError behaves")) + if __name__ == '__main__': import sys -- cgit v1.2.1 From d3deca3c99c299149a30aa3ec760dc17f8abc456 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 18 Feb 2020 12:59:46 -0500 Subject: Added apply_style to CompletionError Simplified error class structure in argparse_completer.py --- cmd2/argparse_completer.py | 56 +++++++++------------------------------- cmd2/cmd2.py | 8 +++--- cmd2/utils.py | 16 +++++++++++- examples/basic_completion.py | 3 +-- tests/test_argparse_completer.py | 39 ---------------------------- 5 files changed, 33 insertions(+), 89 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index add8868c..d75c04d0 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -10,7 +10,6 @@ import argparse import inspect import numbers import shutil -import textwrap from collections import deque from typing import Dict, List, Optional, Union @@ -95,30 +94,8 @@ class _ArgumentState: self.max = self.action.nargs -class _ArgparseCompletionError(CompletionError): - """CompletionError specific to argparse-based tab completion""" - pass - - -# noinspection PyProtectedMember -class _ActionCompletionError(_ArgparseCompletionError): - def __init__(self, arg_action: argparse.Action, completion_error: CompletionError) -> None: - """ - Adds action-specific information to a CompletionError. These are raised when - non-argparse related errors occur during tab completion. - :param arg_action: action being tab completed - :param completion_error: error that occurred - """ - # Indent all lines of completion_error - indented_error = textwrap.indent(str(completion_error), ' ') - - error = ("Error tab completing {}:\n" - "{}".format(argparse._get_action_name(arg_action), indented_error)) - super().__init__(ansi.style_error(error)) - - # noinspection PyProtectedMember -class _UnfinishedFlagError(_ArgparseCompletionError): +class _UnfinishedFlagError(CompletionError): def __init__(self, flag_arg_state: _ArgumentState) -> None: """ CompletionError which occurs when the user has not finished the current flag @@ -128,11 +105,11 @@ class _UnfinishedFlagError(_ArgparseCompletionError): format(argparse._get_action_name(flag_arg_state.action), generate_range_error(flag_arg_state.min, flag_arg_state.max), flag_arg_state.count) - super().__init__(ansi.style_error(error)) + super().__init__(error) # noinspection PyProtectedMember -class _NoResultsError(_ArgparseCompletionError): +class _NoResultsError(CompletionError): def __init__(self, parser: argparse.ArgumentParser, arg_action: argparse.Action) -> None: """ CompletionError which occurs when there are no results. If hinting is allowed, then its message will @@ -151,7 +128,8 @@ class _NoResultsError(_ArgparseCompletionError): formatter.add_argument(arg_action) formatter.end_section() hint_str = formatter.format_help() - super().__init__(hint_str) + # Set apply_style to False because we don't want hints to look like errors + super().__init__(hint_str, apply_style=False) # noinspection PyProtectedMember @@ -253,9 +231,9 @@ class ArgparseCompleter: if arg_action == completer_action: return - error = ansi.style_error("\nError: argument {}: not allowed with argument {}\n". - format(argparse._get_action_name(arg_action), - argparse._get_action_name(completer_action))) + error = ("Error: argument {}: not allowed with argument {}\n". + format(argparse._get_action_name(arg_action), + argparse._get_action_name(completer_action))) raise CompletionError(error) # Mark that this action completed the group @@ -418,13 +396,8 @@ class ArgparseCompleter: # Check if we are completing a flag's argument if flag_arg_state is not None: - try: - completion_results = self._complete_for_arg(flag_arg_state.action, text, line, - begidx, endidx, consumed_arg_values) - except _ArgparseCompletionError as ex: - raise ex - except CompletionError as ex: - raise _ActionCompletionError(flag_arg_state.action, ex) + completion_results = self._complete_for_arg(flag_arg_state.action, text, line, + begidx, endidx, consumed_arg_values) # If we have results, then return them if completion_results: @@ -443,13 +416,8 @@ class ArgparseCompleter: action = remaining_positionals.popleft() pos_arg_state = _ArgumentState(action) - try: - completion_results = self._complete_for_arg(pos_arg_state.action, text, line, - begidx, endidx, consumed_arg_values) - except _ArgparseCompletionError as ex: - raise ex - except CompletionError as ex: - raise _ActionCompletionError(pos_arg_state.action, ex) + completion_results = self._complete_for_arg(pos_arg_state.action, text, line, + begidx, endidx, consumed_arg_values) # If we have results, then return them if completion_results: diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 60d5463a..67304636 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1416,10 +1416,12 @@ class Cmd(cmd.Cmd): except IndexError: return None - except CompletionError as e: - err_str = str(e) + except CompletionError as ex: + err_str = str(ex) + # Don't print error and redraw the prompt unless the error has length if err_str: - # Don't print error and redraw the prompt unless the error has length + if ex.apply_style: + err_str = ansi.style_error(err_str) ansi.style_aware_write(sys.stdout, '\n' + err_str + '\n') rl_force_redisplay() return None diff --git a/cmd2/utils.py b/cmd2/utils.py index b6b45891..6a67c43f 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -75,12 +75,26 @@ def str_to_bool(val: str) -> bool: class CompletionError(Exception): """ Raised during tab completion operations to report any sort of error you want printed by the ArgparseCompleter + This can also be used just to display a message, even if it's not an error. ArgparseCompleter raises + CompletionErrors to display tab completion hints and sets apply_style to False so hints aren't colored + like error text. Example use cases - Reading a database to retrieve a tab completion data set failed - A previous command line argument that determines the data set being completed is invalid + - Tab completion hints """ - pass + def __init__(self, *args, apply_style: bool = True, **kwargs): + """ + Initializer for CompletionError + :param apply_style: If True, then ansi.style_error will be applied to the message text when printed. + Set to False in cases where the message text already has the desired style. + Defaults to True. + """ + self.apply_style = apply_style + + # noinspection PyArgumentList + super().__init__(*args, **kwargs) class Settable: diff --git a/examples/basic_completion.py b/examples/basic_completion.py index b043e157..9523ac67 100755 --- a/examples/basic_completion.py +++ b/examples/basic_completion.py @@ -16,7 +16,6 @@ import functools from typing import List import cmd2 -from cmd2 import ansi # List of strings used with completion functions food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato'] @@ -99,7 +98,7 @@ class BasicCompletion(cmd2.Cmd): - Reading a database to retrieve a tab completion data set failed - A previous command line argument that determines the data set being completed is invalid """ - raise cmd2.CompletionError(ansi.style_error("This is how a CompletionError behaves")) + raise cmd2.CompletionError("This is how a CompletionError behaves") if __name__ == '__main__': diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 83cee30f..9e635a42 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -30,8 +30,6 @@ positional_choices = ['the', 'positional', 'choices'] completions_from_function = ['completions', 'function', 'fairly', 'complete'] completions_from_method = ['completions', 'method', 'missed', 'spot'] -AP_COMP_ERROR_TEXT = "SHOULD ONLY BE THIS TEXT" - def choices_function() -> List[str]: """Function that provides choices""" @@ -234,24 +232,6 @@ class AutoCompleteTester(cmd2.Cmd): def do_raise_completion_error(self, args: argparse.Namespace) -> None: pass - ############################################################################################################ - # Begin code related to _ArgparseCompletionError - ############################################################################################################ - def raise_argparse_completion_error(self): - """Raises ArgparseCompletionError to make sure it gets raised as is""" - from cmd2.argparse_completer import _ArgparseCompletionError - raise _ArgparseCompletionError(AP_COMP_ERROR_TEXT) - - ap_comp_error_parser = Cmd2ArgumentParser() - ap_comp_error_parser.add_argument('pos_ap_comp_err', help='pos ap completion error', - choices_method=raise_argparse_completion_error) - ap_comp_error_parser.add_argument('--flag_ap_comp_err', help='flag ap completion error', - choices_method=raise_argparse_completion_error) - - @with_argparser(ap_comp_error_parser) - def do_raise_ap_completion_error(self, args: argparse.Namespace) -> None: - pass - ############################################################################################################ # Begin code related to receiving arg_tokens ############################################################################################################ @@ -793,25 +773,6 @@ def test_completion_error(ac_app, capsys, args, text): assert "{} broke something".format(text) in out -@pytest.mark.parametrize('arg', [ - # Exercise positional arg that raises _ArgparseCompletionError - '', - - # Exercise flag arg that raises _ArgparseCompletionError - '--flag_ap_comp_err' -]) -def test_argparse_completion_error(ac_app, capsys, arg): - text = '' - line = 'raise_ap_completion_error {} {}'.format(arg, text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, ac_app) - assert first_match is None - out, err = capsys.readouterr() - assert out.strip() == AP_COMP_ERROR_TEXT - - @pytest.mark.parametrize('command_and_args, completions', [ # Exercise a choices function that receives arg_tokens dictionary ('arg_tokens choice subcmd', ['choice', 'subcmd']), -- cgit v1.2.1 From caa7865cf16b4ed96b9307b7f943358534ec4a0a Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 18 Feb 2020 13:07:30 -0500 Subject: Removed extra new line in error message --- cmd2/argparse_completer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index d75c04d0..707b36ba 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -231,7 +231,7 @@ class ArgparseCompleter: if arg_action == completer_action: return - error = ("Error: argument {}: not allowed with argument {}\n". + error = ("Error: argument {}: not allowed with argument {}". format(argparse._get_action_name(arg_action), argparse._get_action_name(completer_action))) raise CompletionError(error) -- cgit v1.2.1 From fb118b9fad6a5916c3264568873a77bb03cb4229 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 18 Feb 2020 13:15:59 -0500 Subject: Updated change log and comment --- CHANGELOG.md | 2 ++ cmd2/cmd2.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dca2d32d..cb81ab17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ * `__file__`: script path (as typed, ~ will be expanded) * Only tab complete after redirection tokens if redirection is allowed * Made `CompletionError` exception available to non-argparse tab completion + * Added `apply_style` to `CompletionError` initializer. It defaults to True, but can be set to False if + you don't want the error text to have `ansi.style_error()` applied to it when printed. * Other * Removed undocumented `py run` command since it was replaced by `run_pyscript` a while ago * Renamed `AutoCompleter` to `ArgparseCompleter` for clarity diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 67304636..caeb4ab0 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1417,8 +1417,8 @@ class Cmd(cmd.Cmd): return None except CompletionError as ex: - err_str = str(ex) # Don't print error and redraw the prompt unless the error has length + err_str = str(ex) if err_str: if ex.apply_style: err_str = ansi.style_error(err_str) -- cgit v1.2.1 From 34ce17c95fae0a849cd90de2e65cd454b9fe51cb Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 18 Feb 2020 15:27:11 -0500 Subject: Redrawing the prompt when an exception occurs during tab completion --- cmd2/cmd2.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index caeb4ab0..0bb4921e 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1429,6 +1429,7 @@ class Cmd(cmd.Cmd): # Insert a newline so the exception doesn't print in the middle of the command line being tab completed self.perror() self.pexcept(e) + rl_force_redisplay() return None def _autocomplete_default(self, text: str, line: str, begidx: int, endidx: int, *, -- cgit v1.2.1 From f7441431697e4ba94202a1484721ae8acf3a4ab7 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Tue, 18 Feb 2020 21:05:10 -0500 Subject: Moved custom cmd2 exceptions to a separate file and removed them from public API --- cmd2/__init__.py | 2 +- cmd2/cmd2.py | 11 +---------- docs/api/exceptions.rst | 4 ---- docs/api/index.rst | 1 - docs/features/hooks.rst | 7 +------ tests/test_cmd2.py | 4 ++-- tests/test_parsing.py | 6 +++--- tests/test_plugin.py | 6 +++--- 8 files changed, 11 insertions(+), 30 deletions(-) delete mode 100644 docs/api/exceptions.rst diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 73d70821..63e27812 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -22,7 +22,7 @@ if cmd2_parser_module is not None: # Get the current value for argparse_custom.DEFAULT_ARGUMENT_PARSER from .argparse_custom import DEFAULT_ARGUMENT_PARSER -from .cmd2 import Cmd, EmptyStatement +from .cmd2 import Cmd from .constants import COMMAND_NAME, DEFAULT_SHORTCUTS from .decorators import categorize, with_argument_list, with_argparser, with_argparser_and_unknown_args, with_category from .parsing import Statement diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 0bb4921e..7b88eaf8 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -50,6 +50,7 @@ from . import utils from .argparse_custom import CompletionItem, DEFAULT_ARGUMENT_PARSER from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer from .decorators import with_argparser +from .exceptions import EmbeddedConsoleExit, EmptyStatement from .history import History, HistoryItem from .parsing import StatementParser, Statement, Macro, MacroArg, shlex_split from .rl_utils import rl_type, RlType, rl_get_point, rl_set_prompt, vt100_support, rl_make_safe_prompt, rl_warning @@ -106,16 +107,6 @@ class _SavedCmd2Env: self.sys_stdin = None -class EmbeddedConsoleExit(SystemExit): - """Custom exception class for use with the py command.""" - pass - - -class EmptyStatement(Exception): - """Custom exception class for handling behavior when the user just presses .""" - pass - - # 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', 'completer_function']) diff --git a/docs/api/exceptions.rst b/docs/api/exceptions.rst deleted file mode 100644 index 400993aa..00000000 --- a/docs/api/exceptions.rst +++ /dev/null @@ -1,4 +0,0 @@ -Exceptions -========== - -.. autoexception:: cmd2.EmptyStatement diff --git a/docs/api/index.rst b/docs/api/index.rst index f0324eab..346fc274 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -6,7 +6,6 @@ API Reference cmd decorators - exceptions ansi utility_classes utility_functions diff --git a/docs/features/hooks.rst b/docs/features/hooks.rst index ee1d5fbc..ba9af573 100644 --- a/docs/features/hooks.rst +++ b/docs/features/hooks.rst @@ -102,12 +102,7 @@ called each time it was registered. Postparsing, precommand, and postcommand hook methods share some common ways to influence the command processing loop. -If a hook raises a ``cmd2.EmptyStatement`` exception: -- no more hooks (except command finalization hooks) of any kind will be called -- if the command has not yet been executed, it will not be executed -- no error message will be displayed to the user - -If a hook raises any other exception: +If a hook raises an exception: - no more hooks (except command finalization hooks) of any kind will be called - if the command has not yet been executed, it will not be executed - the exception message will be displayed for the user. diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index dc9a3fa1..2db52d39 100755 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -20,7 +20,7 @@ except ImportError: from unittest import mock import cmd2 -from cmd2 import ansi, clipboard, constants, plugin, utils, COMMAND_NAME +from cmd2 import ansi, clipboard, constants, exceptions, plugin, utils, COMMAND_NAME from .conftest import (run_cmd, normalize, verify_help_text, HELP_HISTORY, SHORTCUTS_TXT, SHOW_TXT, SHOW_LONG, complete_tester, odd_file_names) @@ -1258,7 +1258,7 @@ def multiline_app(): return app def test_multiline_complete_empty_statement_raises_exception(multiline_app): - with pytest.raises(cmd2.EmptyStatement): + with pytest.raises(exceptions.EmptyStatement): multiline_app._complete_statement('') def test_multiline_complete_statement_without_terminator(multiline_app): diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 2114bfaa..435f22eb 100755 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -7,7 +7,7 @@ import attr import pytest import cmd2 -from cmd2 import constants, utils +from cmd2 import constants, exceptions, utils from cmd2.parsing import StatementParser, shlex_split @pytest.fixture @@ -588,10 +588,10 @@ def test_parse_unclosed_quotes(parser): def test_empty_statement_raises_exception(): app = cmd2.Cmd() - with pytest.raises(cmd2.EmptyStatement): + with pytest.raises(exceptions.EmptyStatement): app._complete_statement('') - with pytest.raises(cmd2.EmptyStatement): + with pytest.raises(exceptions.EmptyStatement): app._complete_statement(' ') @pytest.mark.parametrize('line,command,args', [ diff --git a/tests/test_plugin.py b/tests/test_plugin.py index f7065db5..c118b60d 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -14,7 +14,7 @@ except ImportError: from unittest import mock import cmd2 -from cmd2 import plugin +from cmd2 import exceptions, plugin class Plugin: @@ -81,7 +81,7 @@ class Plugin: def postparse_hook_emptystatement(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.PostparsingData: """A postparsing hook with raises an EmptyStatement exception""" self.called_postparsing += 1 - raise cmd2.EmptyStatement + raise exceptions.EmptyStatement def postparse_hook_exception(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.PostparsingData: """A postparsing hook which raises an exception""" @@ -126,7 +126,7 @@ class Plugin: def precmd_hook_emptystatement(self, data: plugin.PrecommandData) -> plugin.PrecommandData: """A precommand hook which raises an EmptyStatement exception""" self.called_precmd += 1 - raise cmd2.EmptyStatement + raise exceptions.EmptyStatement def precmd_hook_exception(self, data: plugin.PrecommandData) -> plugin.PrecommandData: """A precommand hook which raises an exception""" -- cgit v1.2.1 From 005a918d785b7e619bf567e86536c7cbdc29a29e Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Tue, 18 Feb 2020 21:08:35 -0500 Subject: Oops forgot to commit a file --- cmd2/exceptions.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 cmd2/exceptions.py diff --git a/cmd2/exceptions.py b/cmd2/exceptions.py new file mode 100644 index 00000000..747e2368 --- /dev/null +++ b/cmd2/exceptions.py @@ -0,0 +1,12 @@ +# coding=utf-8 +"""Custom exceptions for cmd2. These are NOT part of the public API and are intended for internal use only.""" + + +class EmbeddedConsoleExit(SystemExit): + """Custom exception class for use with the py command.""" + pass + + +class EmptyStatement(Exception): + """Custom exception class for handling behavior when the user just presses .""" + pass -- cgit v1.2.1 From eecb0ac77e75ae5c6917a6768d8a62cb5789649b Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Tue, 18 Feb 2020 23:40:12 -0500 Subject: Updated CHANGELOG --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb81ab17..6d91aaca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## 0.10.1 (TBD) +## 0.10.1 (February TBD, 2020) * Bug Fixes * Corrected issue where the actual new value was not always being printed in do_set. This occurred in cases where the typed value differed from what the setter had converted it to. @@ -19,6 +19,7 @@ * Other * Removed undocumented `py run` command since it was replaced by `run_pyscript` a while ago * Renamed `AutoCompleter` to `ArgparseCompleter` for clarity + * Custom `EmptyStatement` exception is no longer part of the documented public API ## 0.10.0 (February 7, 2020) * Enhancements -- cgit v1.2.1 From 23a0de46f80972b0ddba33332bbf36c0f1793c3f Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 19 Feb 2020 11:22:50 -0500 Subject: Renamed _autocomplete_default to _complete_argparse_command --- cmd2/cmd2.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 7b88eaf8..26031538 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1254,7 +1254,7 @@ class Cmd(cmd.Cmd): if func is not None and argparser is not None: import functools - compfunc = functools.partial(self._autocomplete_default, + compfunc = functools.partial(self._complete_argparse_command, argparser=argparser, preserve_quotes=getattr(func, constants.CMD_ATTR_PRESERVE_QUOTES)) else: @@ -1423,9 +1423,9 @@ class Cmd(cmd.Cmd): rl_force_redisplay() return None - def _autocomplete_default(self, text: str, line: str, begidx: int, endidx: int, *, - argparser: argparse.ArgumentParser, preserve_quotes: bool) -> List[str]: - """Default completion function for argparse commands""" + def _complete_argparse_command(self, text: str, line: str, begidx: int, endidx: int, *, + argparser: argparse.ArgumentParser, preserve_quotes: bool) -> List[str]: + """Completion function for argparse commands""" from .argparse_completer import ArgparseCompleter completer = ArgparseCompleter(argparser, self) tokens, raw_tokens = self.tokens_for_completion(line, begidx, endidx) -- cgit v1.2.1 From 5ff2c532816a85b3221833dee04417354a8813e1 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 19 Feb 2020 11:23:04 -0500 Subject: Updated change log for release --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d91aaca..c5ec37f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## 0.10.1 (February TBD, 2020) +## 0.10.1 (February 19, 2020) * Bug Fixes * Corrected issue where the actual new value was not always being printed in do_set. This occurred in cases where the typed value differed from what the setter had converted it to. @@ -20,6 +20,10 @@ * Removed undocumented `py run` command since it was replaced by `run_pyscript` a while ago * Renamed `AutoCompleter` to `ArgparseCompleter` for clarity * Custom `EmptyStatement` exception is no longer part of the documented public API +* Notes + * This is a beta release leading up to the 1.0.0 release + * We intend no more breaking changes prior to 1.0.0 + * Just bug fixes, documentation updates, and enhancements ## 0.10.0 (February 7, 2020) * Enhancements -- cgit v1.2.1 From 660d41b404c00db5b757fd6888c50cb4903ad8ac Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 19 Feb 2020 11:57:30 -0500 Subject: Formatting update in change log --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5ec37f0..9cdc1795 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,9 @@ the typed value differed from what the setter had converted it to. * Fixed bug where ANSI style sequences were not correctly handled in `utils.truncate_line()`. * Fixed bug where pyscripts could edit `cmd2.Cmd.py_locals` dictionary. - * Fixed bug where cmd2 set sys.path[0] for a pyscript to cmd2's working directory instead of the + * Fixed bug where cmd2 set `sys.path[0]` for a pyscript to cmd2's working directory instead of the script file's directory. - * Fixed bug where sys.path was not being restored after a pyscript ran. + * Fixed bug where `sys.path` was not being restored after a pyscript ran. * Enhancements * Renamed set command's `-l/--long` flag to `-v/--verbose` for consistency with help and history commands. * Setting the following pyscript variables: -- cgit v1.2.1 From 157efc589cae43e5fb37ac38575d8cdfcd11c9b8 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 20 Feb 2020 12:33:23 -0500 Subject: Moved categorize() to utils.py and made set_parser_prog() non-public --- cmd2/__init__.py | 4 ++-- cmd2/decorators.py | 26 ++++++-------------------- cmd2/utils.py | 15 +++++++++++++++ 3 files changed, 23 insertions(+), 22 deletions(-) diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 63e27812..eb5c275d 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -24,7 +24,7 @@ if cmd2_parser_module is not None: from .argparse_custom import DEFAULT_ARGUMENT_PARSER from .cmd2 import Cmd from .constants import COMMAND_NAME, DEFAULT_SHORTCUTS -from .decorators import categorize, with_argument_list, with_argparser, with_argparser_and_unknown_args, with_category +from .decorators import with_argument_list, with_argparser, with_argparser_and_unknown_args, with_category from .parsing import Statement from .py_bridge import CommandResult -from .utils import CompletionError, Settable +from .utils import categorize, CompletionError, Settable diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 2c812345..ee5db140 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -1,30 +1,16 @@ # coding=utf-8 """Decorators for cmd2 commands""" import argparse -from typing import Callable, Iterable, List, Optional, Union +from typing import Callable, List, Optional, Union from . import constants from .parsing import Statement -def categorize(func: Union[Callable, Iterable[Callable]], category: str) -> None: - """Categorize a function. - - The help command output will group this function under the specified category heading - - :param func: function or list of functions to categorize - :param category: category to put it in - """ - if isinstance(func, Iterable): - for item in func: - setattr(item, constants.CMD_ATTR_HELP_CATEGORY, category) - else: - setattr(func, constants.CMD_ATTR_HELP_CATEGORY, category) - - def with_category(category: str) -> Callable: """A decorator to apply a category to a command function.""" def cat_decorator(func): + from .utils import categorize categorize(func, category) return func return cat_decorator @@ -62,7 +48,7 @@ def with_argument_list(*args: List[Callable], preserve_quotes: bool = False) -> # noinspection PyProtectedMember -def set_parser_prog(parser: argparse.ArgumentParser, prog: str): +def _set_parser_prog(parser: argparse.ArgumentParser, prog: str): """ Recursively set prog attribute of a parser and all of its subparsers so that the root command is a command name and not sys.argv[0]. @@ -79,7 +65,7 @@ def set_parser_prog(parser: argparse.ArgumentParser, prog: str): # Set the prog value for each subcommand for sub_cmd, sub_cmd_parser in action.choices.items(): sub_cmd_prog = parser.prog + ' ' + sub_cmd - set_parser_prog(sub_cmd_parser, sub_cmd_prog) + _set_parser_prog(sub_cmd_parser, sub_cmd_prog) # We can break since argparse only allows 1 group of subcommands per level break @@ -126,7 +112,7 @@ def with_argparser_and_unknown_args(parser: argparse.ArgumentParser, *, # argparser defaults the program name to sys.argv[0], but we want it to be the name of our command command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX):] - set_parser_prog(parser, command_name) + _set_parser_prog(parser, command_name) # If the description has not been set, then use the method docstring if one exists if parser.description is None and func.__doc__: @@ -184,7 +170,7 @@ def with_argparser(parser: argparse.ArgumentParser, *, # argparser defaults the program name to sys.argv[0], but we want it to be the name of our command command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX):] - set_parser_prog(parser, command_name) + _set_parser_prog(parser, command_name) # If the description has not been set, then use the method docstring if one exists if parser.description is None and func.__doc__: diff --git a/cmd2/utils.py b/cmd2/utils.py index 6a67c43f..971a22ce 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -964,3 +964,18 @@ def get_styles_in_text(text: str) -> Dict[int, str]: start += len(match.group()) return styles + + +def categorize(func: Union[Callable, Iterable[Callable]], category: str) -> None: + """Categorize a function. + + The help command output will group this function under the specified category heading + + :param func: function or list of functions to categorize + :param category: category to put it in + """ + if isinstance(func, Iterable): + for item in func: + setattr(item, constants.CMD_ATTR_HELP_CATEGORY, category) + else: + setattr(func, constants.CMD_ATTR_HELP_CATEGORY, category) -- cgit v1.2.1 From a62622a886713c845ba561d98778993c6362f65a Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 20 Feb 2020 12:39:59 -0500 Subject: Fixed docs error --- docs/api/utility_functions.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/utility_functions.rst b/docs/api/utility_functions.rst index 4f788e3d..b348ac1c 100644 --- a/docs/api/utility_functions.rst +++ b/docs/api/utility_functions.rst @@ -7,7 +7,7 @@ Utility Functions .. autofunction:: cmd2.utils.strip_quotes -.. autofunction:: cmd2.decorators.categorize +.. autofunction:: cmd2.utils.categorize .. autofunction:: cmd2.utils.align_text -- cgit v1.2.1 From 22de85832e877b5b360eeacd4b71e00f69bf00e1 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 20 Feb 2020 17:47:40 -0500 Subject: Updated comment --- cmd2/cmd2.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 26031538..b314a683 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3121,8 +3121,9 @@ class Cmd(cmd.Cmd): self._in_py = True py_code_to_run = '' - # Use self.py_locals as the locals() dictionary in the Python environment we are creating, but make - # a copy to prevent pyscripts from editing it. (e.g. locals().clear()). Only make a shallow copy since + # Make a copy of self.py_locals for the locals dictionary in the Python environment we are creating. + # This is to prevent pyscripts from editing it. (e.g. locals().clear()). It also ensures a pyscript's + # environment won't be filled with data from a previously run pyscript. Only make a shallow copy since # it's OK for py_locals to contain objects which are editable in a pyscript. localvars = dict(self.py_locals) localvars[self.py_bridge_name] = py_bridge -- cgit v1.2.1