summaryrefslogtreecommitdiff
path: root/tests_isolated/test_commandset
diff options
context:
space:
mode:
authorEric Lin <anselor@gmail.com>2020-08-05 15:08:37 -0400
committeranselor <anselor@gmail.com>2020-08-06 16:01:19 -0400
commit62eccdac73d852d3ab9df06497bc8c9063e3d283 (patch)
tree2c7a02a8589270447d9ef611f0f6f170e5f0528f /tests_isolated/test_commandset
parent2c99c0d9e7ddea1a93e97e3198aea01beca7c5d5 (diff)
downloadcmd2-git-62eccdac73d852d3ab9df06497bc8c9063e3d283.tar.gz
Verify that a completer function is defined in a CommandSet before
passing it a CommandSet instance. Search for a CommandSet instance that matches the completer's parent class type.` Resolves Issue #967 Renamed isolated_tests directory to tests_isolated for better visual grouping. Added some exception documentation
Diffstat (limited to 'tests_isolated/test_commandset')
-rw-r--r--tests_isolated/test_commandset/__init__.py3
-rw-r--r--tests_isolated/test_commandset/conftest.py215
-rw-r--r--tests_isolated/test_commandset/test_argparse_subcommands.py138
-rw-r--r--tests_isolated/test_commandset/test_commandset.py781
4 files changed, 1137 insertions, 0 deletions
diff --git a/tests_isolated/test_commandset/__init__.py b/tests_isolated/test_commandset/__init__.py
new file mode 100644
index 00000000..037f3866
--- /dev/null
+++ b/tests_isolated/test_commandset/__init__.py
@@ -0,0 +1,3 @@
+#
+# -*- coding: utf-8 -*-
+#
diff --git a/tests_isolated/test_commandset/conftest.py b/tests_isolated/test_commandset/conftest.py
new file mode 100644
index 00000000..037be199
--- /dev/null
+++ b/tests_isolated/test_commandset/conftest.py
@@ -0,0 +1,215 @@
+# coding=utf-8
+"""
+Cmd2 unit/functional testing
+"""
+import sys
+from contextlib import redirect_stderr, redirect_stdout
+from typing import List, Optional, Union
+from unittest import mock
+
+from pytest import fixture
+
+import cmd2
+from cmd2_ext_test import ExternalTestMixin
+from cmd2.utils import StdSim
+
+# Prefer statically linked gnureadline if available (for macOS compatibility due to issues with libedit)
+try:
+ import gnureadline as readline
+except ImportError:
+ # Try to import readline, but allow failure for convenience in Windows unit testing
+ # Note: If this actually fails, you should install readline on Linux or Mac or pyreadline on Windows
+ try:
+ # noinspection PyUnresolvedReferences
+ import readline
+ except ImportError:
+ pass
+
+
+def verify_help_text(cmd2_app: cmd2.Cmd,
+ help_output: Union[str, List[str]],
+ verbose_strings: Optional[List[str]] = None) -> None:
+ """This function verifies that all expected commands are present in the help text.
+
+ :param cmd2_app: instance of cmd2.Cmd
+ :param help_output: output of help, either as a string or list of strings
+ :param verbose_strings: optional list of verbose strings to search for
+ """
+ if isinstance(help_output, str):
+ help_text = help_output
+ else:
+ help_text = ''.join(help_output)
+ commands = cmd2_app.get_visible_commands()
+ for command in commands:
+ assert command in help_text
+
+ if verbose_strings:
+ for verbose_string in verbose_strings:
+ assert verbose_string in help_text
+
+
+# Help text for the history command
+HELP_HISTORY = """Usage: history [-h] [-r | -e | -o FILE | -t TRANSCRIPT_FILE | -c] [-s] [-x]
+ [-v] [-a]
+ [arg]
+
+View, run, edit, save, or clear previously entered commands
+
+positional arguments:
+ arg empty all history items
+ a one history item by number
+ a..b, a:b, a:, ..b items by indices (inclusive)
+ string items containing string
+ /regex/ items matching regular expression
+
+optional arguments:
+ -h, --help show this help message and exit
+ -r, --run run selected history items
+ -e, --edit edit and then run selected history items
+ -o, --output_file FILE
+ output commands to a script file, implies -s
+ -t, --transcript TRANSCRIPT_FILE
+ output commands and results to a transcript file,
+ implies -s
+ -c, --clear clear all history
+
+formatting:
+ -s, --script output commands in script format, i.e. without command
+ numbers
+ -x, --expanded output fully parsed commands with any aliases and
+ macros expanded, instead of typed commands
+ -v, --verbose display history and include expanded commands if they
+ differ from the typed command
+ -a, --all display all commands, including ones persisted from
+ previous sessions
+"""
+
+# Output from the shortcuts command with default built-in shortcuts
+SHORTCUTS_TXT = """Shortcuts for other commands:
+!: shell
+?: help
+@: run_script
+@@: _relative_run_script
+"""
+
+# Output from the show command with default settings
+SHOW_TXT = """allow_style: 'Terminal'
+debug: False
+echo: False
+editor: 'vim'
+feedback_to_output: False
+max_completion_items: 50
+quiet: False
+timing: False
+"""
+
+SHOW_LONG = """
+allow_style: 'Terminal' # Allow ANSI text style sequences in output (valid values: Terminal, Always, Never)
+debug: False # Show full traceback on exception
+echo: False # Echo command issued into output
+editor: 'vim' # Program used by 'edit'
+feedback_to_output: False # Include nonessentials in '|', '>' results
+max_completion_items: 50 # Maximum number of CompletionItems to display during tab completion
+quiet: False # Don't print nonessential feedback
+timing: False # Report execution times
+"""
+
+
+def normalize(block):
+ """ Normalize a block of text to perform comparison.
+
+ Strip newlines from the very beginning and very end Then split into separate lines and strip trailing whitespace
+ from each line.
+ """
+ assert isinstance(block, str)
+ block = block.strip('\n')
+ return [line.rstrip() for line in block.splitlines()]
+
+
+def run_cmd(app, cmd):
+ """ Clear out and err StdSim buffers, run the command, and return out and err """
+ saved_sysout = sys.stdout
+ sys.stdout = app.stdout
+
+ # This will be used to capture app.stdout and sys.stdout
+ copy_cmd_stdout = StdSim(app.stdout)
+
+ # This will be used to capture sys.stderr
+ copy_stderr = StdSim(sys.stderr)
+
+ try:
+ app.stdout = copy_cmd_stdout
+ with redirect_stdout(copy_cmd_stdout):
+ with redirect_stderr(copy_stderr):
+ app.onecmd_plus_hooks(cmd)
+ finally:
+ app.stdout = copy_cmd_stdout.inner_stream
+ sys.stdout = saved_sysout
+
+ out = copy_cmd_stdout.getvalue()
+ err = copy_stderr.getvalue()
+ return normalize(out), normalize(err)
+
+
+@fixture
+def base_app():
+ return cmd2.Cmd()
+
+
+# These are odd file names for testing quoting of them
+odd_file_names = [
+ 'nothingweird',
+ 'has spaces',
+ '"is_double_quoted"',
+ "'is_single_quoted'"
+]
+
+
+def complete_tester(text: str, line: str, begidx: int, endidx: int, app) -> Optional[str]:
+ """
+ This is a convenience function to test cmd2.complete() since
+ in a unit test environment there is no actual console readline
+ is monitoring. Therefore we use mock to provide readline data
+ to complete().
+
+ :param text: the string prefix we are attempting to match
+ :param line: the current input line with leading whitespace removed
+ :param begidx: the beginning index of the prefix text
+ :param endidx: the ending index of the prefix text
+ :param app: the cmd2 app that will run completions
+ :return: The first matched string or None if there are no matches
+ Matches are stored in app.completion_matches
+ These matches also have been sorted by complete()
+ """
+ def get_line():
+ return line
+
+ def get_begidx():
+ return begidx
+
+ def get_endidx():
+ return endidx
+
+ # Run the readline tab completion function with readline mocks in place
+ with mock.patch.object(readline, 'get_line_buffer', get_line):
+ with mock.patch.object(readline, 'get_begidx', get_begidx):
+ with mock.patch.object(readline, 'get_endidx', get_endidx):
+ return app.complete(text, 0)
+
+
+class WithCommandSets(ExternalTestMixin, cmd2.Cmd):
+ """Class for testing custom help_* methods which override docstring help."""
+ def __init__(self, *args, **kwargs):
+ super(WithCommandSets, self).__init__(*args, **kwargs)
+
+
+@fixture
+def command_sets_app():
+ app = WithCommandSets()
+ return app
+
+
+@fixture()
+def command_sets_manual():
+ app = WithCommandSets(auto_load_commands=False)
+ return app
diff --git a/tests_isolated/test_commandset/test_argparse_subcommands.py b/tests_isolated/test_commandset/test_argparse_subcommands.py
new file mode 100644
index 00000000..69a53447
--- /dev/null
+++ b/tests_isolated/test_commandset/test_argparse_subcommands.py
@@ -0,0 +1,138 @@
+# coding=utf-8
+# flake8: noqa E302
+"""
+reproduces test_argparse.py except with SubCommands
+"""
+
+import argparse
+
+import pytest
+
+import cmd2
+from .conftest import run_cmd, WithCommandSets
+
+
+class SubcommandSet(cmd2.CommandSet):
+ """ Example cmd2 application where we a base command which has a couple subcommands."""
+
+ def __init__(self, dummy):
+ super(SubcommandSet, self).__init__()
+
+ # subcommand functions for the base command
+ def base_foo(self, cmd: cmd2.Cmd, args):
+ """foo subcommand of base command"""
+ cmd.poutput(args.x * args.y)
+
+ def base_bar(self, cmd: cmd2.Cmd, args):
+ """bar subcommand of base command"""
+ cmd.poutput('((%s))' % args.z)
+
+ def base_helpless(self, cmd: cmd2.Cmd, args):
+ """helpless subcommand of base command"""
+ cmd.poutput('((%s))' % args.z)
+
+ # create the top-level parser for the base command
+ base_parser = argparse.ArgumentParser()
+ base_subparsers = base_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND')
+ base_subparsers.required = True
+
+ # create the parser for the "foo" subcommand
+ parser_foo = base_subparsers.add_parser('foo', help='foo help')
+ parser_foo.add_argument('-x', type=int, default=1, help='integer')
+ parser_foo.add_argument('y', type=float, help='float')
+ parser_foo.set_defaults(func=base_foo)
+
+ # create the parser for the "bar" subcommand
+ parser_bar = base_subparsers.add_parser('bar', help='bar help', aliases=['bar_1', 'bar_2'])
+ parser_bar.add_argument('z', help='string')
+ parser_bar.set_defaults(func=base_bar)
+
+ # create the parser for the "helpless" subcommand
+ # This subcommand has aliases and no help text. It exists to prevent changes to _set_parser_prog() which
+ # use an approach which relies on action._choices_actions list. See comment in that function for more
+ # details.
+ parser_bar = base_subparsers.add_parser('helpless', aliases=['helpless_1', 'helpless_2'])
+ parser_bar.add_argument('z', help='string')
+ parser_bar.set_defaults(func=base_bar)
+
+ @cmd2.with_argparser(base_parser)
+ def do_base(self, cmd: cmd2.Cmd, args):
+ """Base command help"""
+ # Call whatever subcommand function was selected
+ func = getattr(args, 'func')
+ func(self, cmd, args)
+
+
+@pytest.fixture
+def subcommand_app():
+ app = WithCommandSets(auto_load_commands=False,
+ command_sets=[SubcommandSet(1)])
+ return app
+
+
+def test_subcommand_foo(subcommand_app):
+ out, err = run_cmd(subcommand_app, 'base foo -x2 5.0')
+ assert out == ['10.0']
+
+
+def test_subcommand_bar(subcommand_app):
+ out, err = run_cmd(subcommand_app, 'base bar baz')
+ assert out == ['((baz))']
+
+
+def test_subcommand_invalid(subcommand_app):
+ out, err = run_cmd(subcommand_app, 'base baz')
+ assert err[0].startswith('usage: base')
+ assert err[1].startswith("base: error: argument SUBCOMMAND: invalid choice: 'baz'")
+
+
+def test_subcommand_base_help(subcommand_app):
+ out, err = run_cmd(subcommand_app, 'help base')
+ assert out[0].startswith('usage: base')
+ assert out[1] == ''
+ assert out[2] == 'Base command help'
+
+
+def test_subcommand_help(subcommand_app):
+ # foo has no aliases
+ out, err = run_cmd(subcommand_app, 'help base foo')
+ assert out[0].startswith('usage: base foo')
+ assert out[1] == ''
+ assert out[2] == 'positional arguments:'
+
+ # bar has aliases (usage should never show alias name)
+ out, err = run_cmd(subcommand_app, 'help base bar')
+ assert out[0].startswith('usage: base bar')
+ assert out[1] == ''
+ assert out[2] == 'positional arguments:'
+
+ out, err = run_cmd(subcommand_app, 'help base bar_1')
+ assert out[0].startswith('usage: base bar')
+ assert out[1] == ''
+ assert out[2] == 'positional arguments:'
+
+ out, err = run_cmd(subcommand_app, 'help base bar_2')
+ assert out[0].startswith('usage: base bar')
+ assert out[1] == ''
+ assert out[2] == 'positional arguments:'
+
+ # helpless has aliases and no help text (usage should never show alias name)
+ out, err = run_cmd(subcommand_app, 'help base helpless')
+ assert out[0].startswith('usage: base helpless')
+ assert out[1] == ''
+ assert out[2] == 'positional arguments:'
+
+ out, err = run_cmd(subcommand_app, 'help base helpless_1')
+ assert out[0].startswith('usage: base helpless')
+ assert out[1] == ''
+ assert out[2] == 'positional arguments:'
+
+ out, err = run_cmd(subcommand_app, 'help base helpless_2')
+ assert out[0].startswith('usage: base helpless')
+ assert out[1] == ''
+ assert out[2] == 'positional arguments:'
+
+
+def test_subcommand_invalid_help(subcommand_app):
+ out, err = run_cmd(subcommand_app, 'help base baz')
+ assert out[0].startswith('usage: base')
diff --git a/tests_isolated/test_commandset/test_commandset.py b/tests_isolated/test_commandset/test_commandset.py
new file mode 100644
index 00000000..506b309d
--- /dev/null
+++ b/tests_isolated/test_commandset/test_commandset.py
@@ -0,0 +1,781 @@
+# coding=utf-8
+# flake8: noqa E302
+"""
+Test CommandSet
+"""
+
+import argparse
+from typing import List
+
+import pytest
+
+import cmd2
+from cmd2 import utils
+from .conftest import complete_tester, WithCommandSets
+from cmd2.exceptions import CommandSetRegistrationError
+
+
+@cmd2.with_default_category('Fruits')
+class CommandSetA(cmd2.CommandSet):
+ def do_apple(self, cmd: cmd2.Cmd, statement: cmd2.Statement):
+ cmd.poutput('Apple!')
+
+ def do_banana(self, cmd: cmd2.Cmd, statement: cmd2.Statement):
+ """Banana Command"""
+ cmd.poutput('Banana!!')
+
+ cranberry_parser = cmd2.Cmd2ArgumentParser('cranberry')
+ cranberry_parser.add_argument('arg1', choices=['lemonade', 'juice', 'sauce'])
+
+ @cmd2.with_argparser(cranberry_parser, with_unknown_args=True)
+ def do_cranberry(self, cmd: cmd2.Cmd, ns: argparse.Namespace, unknown: List[str]):
+ cmd.poutput('Cranberry {}!!'.format(ns.arg1))
+ if unknown and len(unknown):
+ cmd.poutput('Unknown: ' + ', '.join(['{}']*len(unknown)).format(*unknown))
+ cmd.last_result = {'arg1': ns.arg1,
+ 'unknown': unknown}
+
+ def help_cranberry(self, cmd: cmd2.Cmd):
+ cmd.stdout.write('This command does diddly squat...\n')
+
+ @cmd2.with_argument_list
+ @cmd2.with_category('Also Alone')
+ def do_durian(self, cmd: cmd2.Cmd, args: List[str]):
+ """Durian Command"""
+ cmd.poutput('{} Arguments: '.format(len(args)))
+ cmd.poutput(', '.join(['{}']*len(args)).format(*args))
+ cmd.last_result = {'args': args}
+
+ def complete_durian(self, cmd: cmd2.Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]:
+ return utils.basic_complete(text, line, begidx, endidx, ['stinks', 'smells', 'disgusting'])
+
+ elderberry_parser = cmd2.Cmd2ArgumentParser('elderberry')
+ elderberry_parser.add_argument('arg1')
+
+ @cmd2.with_category('Alone')
+ @cmd2.with_argparser(elderberry_parser)
+ def do_elderberry(self, cmd: cmd2.Cmd, ns: argparse.Namespace):
+ cmd.poutput('Elderberry {}!!'.format(ns.arg1))
+ cmd.last_result = {'arg1': ns.arg1}
+
+
+@cmd2.with_default_category('Command Set B')
+class CommandSetB(cmd2.CommandSet):
+ def __init__(self, arg1):
+ super().__init__()
+ self._arg1 = arg1
+
+ def do_aardvark(self, cmd: cmd2.Cmd, statement: cmd2.Statement):
+ cmd.poutput('Aardvark!')
+
+ def do_bat(self, cmd: cmd2.Cmd, statement: cmd2.Statement):
+ """Banana Command"""
+ cmd.poutput('Bat!!')
+
+ def do_crocodile(self, cmd: cmd2.Cmd, statement: cmd2.Statement):
+ cmd.poutput('Crocodile!!')
+
+
+def test_autoload_commands(command_sets_app):
+ # verifies that, when autoload is enabled, CommandSets and registered functions all show up
+
+ cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_app._build_command_info()
+
+ assert 'Alone' in cmds_cats
+ assert 'elderberry' in cmds_cats['Alone']
+
+ assert 'Also Alone' in cmds_cats
+ assert 'durian' in cmds_cats['Also Alone']
+
+ assert 'Fruits' in cmds_cats
+ assert 'cranberry' in cmds_cats['Fruits']
+
+ assert 'Command Set B' not in cmds_cats
+
+
+def test_custom_construct_commandsets():
+ # Verifies that a custom initialized CommandSet loads correctly when passed into the constructor
+ command_set = CommandSetB('foo')
+ app = WithCommandSets(command_sets=[command_set])
+
+ cmds_cats, cmds_doc, cmds_undoc, help_topics = app._build_command_info()
+ assert 'Command Set B' in cmds_cats
+
+ # Verifies that the same CommandSet can not be loaded twice
+ command_set_2 = CommandSetB('bar')
+ with pytest.raises(CommandSetRegistrationError):
+ assert app.install_command_set(command_set_2)
+
+ # Verify that autoload doesn't conflict with a manually loaded CommandSet that could be autoloaded.
+ app2 = WithCommandSets(command_sets=[CommandSetA()])
+ assert hasattr(app2, 'do_apple')
+
+
+def test_load_commands(command_sets_manual):
+
+ # now install a command set and verify the commands are now present
+ cmd_set = CommandSetA()
+ command_sets_manual.install_command_set(cmd_set)
+
+ cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info()
+
+ assert 'Alone' in cmds_cats
+ assert 'elderberry' in cmds_cats['Alone']
+
+ assert 'Fruits' in cmds_cats
+ assert 'cranberry' in cmds_cats['Fruits']
+
+ # uninstall the command set and verify it is now also no longer accessible
+ command_sets_manual.uninstall_command_set(cmd_set)
+
+ cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info()
+
+ assert 'Alone' not in cmds_cats
+ assert 'Fruits' not in cmds_cats
+
+ # uninstall a second time and verify no errors happen
+ command_sets_manual.uninstall_command_set(cmd_set)
+
+ # reinstall the command set and verify it is accessible
+ command_sets_manual.install_command_set(cmd_set)
+
+ cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info()
+
+ assert 'Alone' in cmds_cats
+ assert 'elderberry' in cmds_cats['Alone']
+
+ assert 'Fruits' in cmds_cats
+ assert 'cranberry' in cmds_cats['Fruits']
+
+
+def test_partial_with_passthru():
+
+ def test_func(arg1, arg2):
+ """Documentation Comment"""
+ print('Do stuff {} - {}'.format(arg1, arg2))
+
+ my_partial = cmd2.command_definition._partial_passthru(test_func, 1)
+
+ setattr(test_func, 'Foo', 5)
+
+ assert hasattr(my_partial, 'Foo')
+
+ assert getattr(my_partial, 'Foo', None) == 5
+
+ a = dir(test_func)
+ b = dir(my_partial)
+ assert a == b
+
+ assert not hasattr(test_func, 'Bar')
+ setattr(my_partial, 'Bar', 6)
+ assert hasattr(test_func, 'Bar')
+
+ assert getattr(test_func, 'Bar', None) == 6
+
+
+def test_commandset_decorators(command_sets_app):
+ result = command_sets_app.app_cmd('cranberry juice extra1 extra2')
+ assert len(result.data['unknown']) == 2
+ assert 'extra1' in result.data['unknown']
+ assert 'extra2' in result.data['unknown']
+ assert result.data['arg1'] == 'juice'
+ assert result.stderr is None
+
+ result = command_sets_app.app_cmd('durian juice extra1 extra2')
+ assert len(result.data['args']) == 3
+ assert 'juice' in result.data['args']
+ assert 'extra1' in result.data['args']
+ assert 'extra2' in result.data['args']
+ assert result.stderr is None
+
+ result = command_sets_app.app_cmd('durian')
+ assert len(result.data['args']) == 0
+ assert result.stderr is None
+
+ result = command_sets_app.app_cmd('elderberry')
+ assert result.stderr is not None
+ assert len(result.stderr) > 0
+ assert 'arguments are required' in result.stderr
+ assert result.data is None
+
+ result = command_sets_app.app_cmd('elderberry a b')
+ assert result.stderr is not None
+ assert len(result.stderr) > 0
+ assert 'unrecognized arguments' in result.stderr
+ assert result.data is None
+
+
+def test_load_commandset_errors(command_sets_manual, capsys):
+ cmd_set = CommandSetA()
+
+ # create a conflicting command before installing CommandSet to verify rollback behavior
+ command_sets_manual._install_command_function('durian', cmd_set.do_durian)
+ with pytest.raises(CommandSetRegistrationError):
+ command_sets_manual.install_command_set(cmd_set)
+
+ # verify that the commands weren't installed
+ cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info()
+
+ assert 'Alone' not in cmds_cats
+ assert 'Fruits' not in cmds_cats
+ assert not command_sets_manual._installed_command_sets
+
+ delattr(command_sets_manual, 'do_durian')
+
+ # pre-create intentionally conflicting macro and alias names
+ command_sets_manual.app_cmd('macro create apple run_pyscript')
+ command_sets_manual.app_cmd('alias create banana run_pyscript')
+
+ # now install a command set and verify the commands are now present
+ command_sets_manual.install_command_set(cmd_set)
+ out, err = capsys.readouterr()
+
+ # verify aliases and macros are deleted with warning if they conflict with a command
+ assert "Deleting alias 'banana'" in err
+ assert "Deleting macro 'apple'" in err
+
+ # verify duplicate commands are detected
+ with pytest.raises(CommandSetRegistrationError):
+ command_sets_manual._install_command_function('banana', cmd_set.do_banana)
+
+ # verify bad command names are detected
+ with pytest.raises(CommandSetRegistrationError):
+ command_sets_manual._install_command_function('bad command', cmd_set.do_banana)
+
+ # verify error conflict with existing completer function
+ with pytest.raises(CommandSetRegistrationError):
+ command_sets_manual._install_completer_function('durian', cmd_set.complete_durian)
+
+ # verify error conflict with existing help function
+ with pytest.raises(CommandSetRegistrationError):
+ command_sets_manual._install_help_function('cranberry', cmd_set.help_cranberry)
+
+
+class LoadableBase(cmd2.CommandSet):
+ def __init__(self, dummy):
+ super(LoadableBase, self).__init__()
+ self._dummy = dummy # prevents autoload
+
+ cut_parser = cmd2.Cmd2ArgumentParser('cut')
+ cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut')
+
+ @cmd2.with_argparser(cut_parser)
+ def do_cut(self, cmd: cmd2.Cmd, ns: argparse.Namespace):
+ """Cut something"""
+ handler = ns.get_handler()
+ if handler is not None:
+ # Call whatever subcommand function was selected
+ handler(ns)
+ else:
+ # No subcommand was provided, so call help
+ cmd.pwarning('This command does nothing without sub-parsers registered')
+ cmd.do_help('cut')
+
+
+ stir_parser = cmd2.Cmd2ArgumentParser('stir')
+ stir_subparsers = stir_parser.add_subparsers(title='item', help='what to stir')
+
+ @cmd2.with_argparser(stir_parser)
+ def do_stir(self, cmd: cmd2.Cmd, ns: argparse.Namespace):
+ """Stir something"""
+ handler = ns.get_handler()
+ if handler is not None:
+ # Call whatever subcommand function was selected
+ handler(ns)
+ else:
+ # No subcommand was provided, so call help
+ cmd.pwarning('This command does nothing without sub-parsers registered')
+ cmd.do_help('stir')
+
+ stir_pasta_parser = cmd2.Cmd2ArgumentParser('pasta', add_help=False)
+ stir_pasta_parser.add_argument('--option', '-o')
+ stir_pasta_parser.add_subparsers(title='style', help='Stir style')
+
+ @cmd2.as_subcommand_to('stir', 'pasta', stir_pasta_parser)
+ def stir_pasta(self, cmd: cmd2.Cmd, ns: argparse.Namespace):
+ handler = ns.get_handler()
+ if handler is not None:
+ # Call whatever subcommand function was selected
+ handler(ns)
+ else:
+ cmd.poutput('Stir pasta haphazardly')
+
+
+class LoadableBadBase(cmd2.CommandSet):
+ def __init__(self, dummy):
+ super(LoadableBadBase, self).__init__()
+ self._dummy = dummy # prevents autoload
+
+ def do_cut(self, cmd: cmd2.Cmd, ns: argparse.Namespace):
+ """Cut something"""
+ handler = ns.get_handler()
+ if handler is not None:
+ # Call whatever subcommand function was selected
+ handler(ns)
+ else:
+ # No subcommand was provided, so call help
+ cmd.poutput('This command does nothing without sub-parsers registered')
+ cmd.do_help('cut')
+
+
+@cmd2.with_default_category('Fruits')
+class LoadableFruits(cmd2.CommandSet):
+ def __init__(self, dummy):
+ super(LoadableFruits, self).__init__()
+ self._dummy = dummy # prevents autoload
+
+ def do_apple(self, cmd: cmd2.Cmd, _: cmd2.Statement):
+ cmd.poutput('Apple')
+
+ banana_parser = cmd2.Cmd2ArgumentParser(add_help=False)
+ banana_parser.add_argument('direction', choices=['discs', 'lengthwise'])
+
+ @cmd2.as_subcommand_to('cut', 'banana', banana_parser, help_text='Cut banana', aliases=['bananer'])
+ def cut_banana(self, cmd: cmd2.Cmd, ns: argparse.Namespace):
+ """Cut banana"""
+ cmd.poutput('cutting banana: ' + ns.direction)
+
+
+class LoadablePastaStir(cmd2.CommandSet):
+ def __init__(self, dummy):
+ super(LoadablePastaStir, self).__init__()
+ self._dummy = dummy # prevents autoload
+
+ stir_pasta_vigor_parser = cmd2.Cmd2ArgumentParser('vigor', add_help=False)
+ stir_pasta_vigor_parser.add_argument('frequency')
+
+ @cmd2.as_subcommand_to('stir pasta', 'vigorously', stir_pasta_vigor_parser)
+ def stir_pasta_vigorously(self, cmd: cmd2.Cmd, ns: argparse.Namespace):
+ cmd.poutput('stir the pasta vigorously')
+
+
+@cmd2.with_default_category('Vegetables')
+class LoadableVegetables(cmd2.CommandSet):
+ def __init__(self, dummy):
+ super(LoadableVegetables, self).__init__()
+ self._dummy = dummy # prevents autoload
+
+ def do_arugula(self, cmd: cmd2.Cmd, _: cmd2.Statement):
+ cmd.poutput('Arugula')
+
+ def complete_style_arg(self, cmd: cmd2.Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]:
+ return ['quartered', 'diced']
+
+ bokchoy_parser = cmd2.Cmd2ArgumentParser(add_help=False)
+ bokchoy_parser.add_argument('style', completer_method=complete_style_arg)
+
+ @cmd2.as_subcommand_to('cut', 'bokchoy', bokchoy_parser)
+ def cut_bokchoy(self, cmd: cmd2.Cmd, _: cmd2.Statement):
+ cmd.poutput('Bok Choy')
+
+
+def test_subcommands(command_sets_manual):
+
+ base_cmds = LoadableBase(1)
+ badbase_cmds = LoadableBadBase(1)
+ fruit_cmds = LoadableFruits(1)
+ veg_cmds = LoadableVegetables(1)
+
+ # installing subcommands without base command present raises exception
+ with pytest.raises(CommandSetRegistrationError):
+ command_sets_manual.install_command_set(fruit_cmds)
+
+ # if the base command is present but isn't an argparse command, expect exception
+ command_sets_manual.install_command_set(badbase_cmds)
+ with pytest.raises(CommandSetRegistrationError):
+ command_sets_manual.install_command_set(fruit_cmds)
+
+ # verify that the commands weren't installed
+ cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info()
+ assert 'cut' in cmds_doc
+ assert 'Fruits' not in cmds_cats
+
+ # Now install the good base commands
+ command_sets_manual.uninstall_command_set(badbase_cmds)
+ command_sets_manual.install_command_set(base_cmds)
+
+ # verify that we catch an attempt to register subcommands when the commandset isn't installed
+ with pytest.raises(CommandSetRegistrationError):
+ command_sets_manual._register_subcommands(fruit_cmds)
+
+ cmd_result = command_sets_manual.app_cmd('cut')
+ assert 'This command does nothing without sub-parsers registered' in cmd_result.stderr
+
+ # verify that command set install without problems
+ command_sets_manual.install_command_set(fruit_cmds)
+ command_sets_manual.install_command_set(veg_cmds)
+ cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info()
+ assert 'Fruits' in cmds_cats
+
+ text = ''
+ line = 'cut {}'.format(text)
+ endidx = len(line)
+ begidx = endidx
+ first_match = complete_tester(text, line, begidx, endidx, command_sets_manual)
+
+ assert first_match is not None
+ # check that the alias shows up correctly
+ assert ['banana', 'bananer', 'bokchoy'] == command_sets_manual.completion_matches
+
+ cmd_result = command_sets_manual.app_cmd('cut banana discs')
+ assert 'cutting banana: discs' in cmd_result.stdout
+
+ text = ''
+ line = 'cut bokchoy {}'.format(text)
+ endidx = len(line)
+ begidx = endidx
+ first_match = complete_tester(text, line, begidx, endidx, command_sets_manual)
+
+ assert first_match is not None
+ # verify that argparse completer in commandset functions correctly
+ assert ['diced', 'quartered'] == command_sets_manual.completion_matches
+
+ # verify that command set uninstalls without problems
+ command_sets_manual.uninstall_command_set(fruit_cmds)
+ cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info()
+ assert 'Fruits' not in cmds_cats
+
+ # verify a double-unregister raises exception
+ with pytest.raises(CommandSetRegistrationError):
+ command_sets_manual._unregister_subcommands(fruit_cmds)
+ command_sets_manual.uninstall_command_set(veg_cmds)
+
+ # Disable command and verify subcommands still load and unload
+ command_sets_manual.disable_command('cut', 'disabled for test')
+
+ # verify that command set install without problems
+ command_sets_manual.install_command_set(fruit_cmds)
+ command_sets_manual.install_command_set(veg_cmds)
+
+ command_sets_manual.enable_command('cut')
+
+ cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info()
+ assert 'Fruits' in cmds_cats
+
+ text = ''
+ line = 'cut {}'.format(text)
+ endidx = len(line)
+ begidx = endidx
+ first_match = complete_tester(text, line, begidx, endidx, command_sets_manual)
+
+ assert first_match is not None
+ # check that the alias shows up correctly
+ assert ['banana', 'bananer', 'bokchoy'] == command_sets_manual.completion_matches
+
+ text = ''
+ line = 'cut bokchoy {}'.format(text)
+ endidx = len(line)
+ begidx = endidx
+ first_match = complete_tester(text, line, begidx, endidx, command_sets_manual)
+
+ assert first_match is not None
+ # verify that argparse completer in commandset functions correctly
+ assert ['diced', 'quartered'] == command_sets_manual.completion_matches
+
+ # disable again and verify can still uninstnall
+ command_sets_manual.disable_command('cut', 'disabled for test')
+
+ # verify that command set uninstalls without problems
+ command_sets_manual.uninstall_command_set(fruit_cmds)
+ cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info()
+ assert 'Fruits' not in cmds_cats
+
+ # verify a double-unregister raises exception
+ with pytest.raises(CommandSetRegistrationError):
+ command_sets_manual._unregister_subcommands(fruit_cmds)
+
+ with pytest.raises(CommandSetRegistrationError):
+ command_sets_manual.uninstall_command_set(base_cmds)
+
+ command_sets_manual.uninstall_command_set(veg_cmds)
+ command_sets_manual.uninstall_command_set(base_cmds)
+
+def test_nested_subcommands(command_sets_manual):
+ base_cmds = LoadableBase(1)
+ # fruit_cmds = LoadableFruits(1)
+ # veg_cmds = LoadableVegetables(1)
+ pasta_cmds = LoadablePastaStir(1)
+
+ with pytest.raises(CommandSetRegistrationError):
+ command_sets_manual.install_command_set(pasta_cmds)
+
+ command_sets_manual.install_command_set(base_cmds)
+
+ command_sets_manual.install_command_set(pasta_cmds)
+
+ with pytest.raises(CommandSetRegistrationError):
+ command_sets_manual.uninstall_command_set(base_cmds)
+
+ class BadNestedSubcommands(cmd2.CommandSet):
+ def __init__(self, dummy):
+ super(BadNestedSubcommands, self).__init__()
+ self._dummy = dummy # prevents autoload
+
+ stir_pasta_vigor_parser = cmd2.Cmd2ArgumentParser('vigor', add_help=False)
+ stir_pasta_vigor_parser.add_argument('frequency')
+
+ @cmd2.as_subcommand_to('stir sauce', 'vigorously', stir_pasta_vigor_parser)
+ def stir_pasta_vigorously(self, cmd: cmd2.Cmd, ns: argparse.Namespace):
+ cmd.poutput('stir the pasta vigorously')
+
+ with pytest.raises(CommandSetRegistrationError):
+ command_sets_manual.install_command_set(BadNestedSubcommands(1))
+
+
+class AppWithSubCommands(cmd2.Cmd):
+ """Class for testing usage of `as_subcommand_to` decorator directly in a Cmd2 subclass."""
+ def __init__(self, *args, **kwargs):
+ super(AppWithSubCommands, self).__init__(*args, **kwargs)
+
+ cut_parser = cmd2.Cmd2ArgumentParser('cut')
+ cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut')
+
+ @cmd2.with_argparser(cut_parser)
+ def do_cut(self, ns: argparse.Namespace):
+ """Cut something"""
+ handler = ns.get_handler()
+ if handler is not None:
+ # Call whatever subcommand function was selected
+ handler(ns)
+ else:
+ # No subcommand was provided, so call help
+ self.poutput('This command does nothing without sub-parsers registered')
+ self.do_help('cut')
+
+ banana_parser = cmd2.Cmd2ArgumentParser(add_help=False)
+ banana_parser.add_argument('direction', choices=['discs', 'lengthwise'])
+
+ @cmd2.as_subcommand_to('cut', 'banana', banana_parser, help_text='Cut banana', aliases=['bananer'])
+ def cut_banana(self, ns: argparse.Namespace):
+ """Cut banana"""
+ self.poutput('cutting banana: ' + ns.direction)
+
+ def complete_style_arg(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
+ return ['quartered', 'diced']
+
+ bokchoy_parser = cmd2.Cmd2ArgumentParser(add_help=False)
+ bokchoy_parser.add_argument('style', completer_method=complete_style_arg)
+
+ @cmd2.as_subcommand_to('cut', 'bokchoy', bokchoy_parser)
+ def cut_bokchoy(self, _: cmd2.Statement):
+ self.poutput('Bok Choy')
+
+
+@pytest.fixture
+def static_subcommands_app():
+ app = AppWithSubCommands()
+ return app
+
+
+def test_static_subcommands(static_subcommands_app):
+ cmds_cats, cmds_doc, cmds_undoc, help_topics = static_subcommands_app._build_command_info()
+ assert 'Fruits' in cmds_cats
+
+ text = ''
+ line = 'cut {}'.format(text)
+ endidx = len(line)
+ begidx = endidx
+ first_match = complete_tester(text, line, begidx, endidx, static_subcommands_app)
+
+ assert first_match is not None
+ # check that the alias shows up correctly
+ assert ['banana', 'bananer', 'bokchoy'] == static_subcommands_app.completion_matches
+
+ text = ''
+ line = 'cut bokchoy {}'.format(text)
+ endidx = len(line)
+ begidx = endidx
+ first_match = complete_tester(text, line, begidx, endidx, static_subcommands_app)
+
+ assert first_match is not None
+ # verify that argparse completer in commandset functions correctly
+ assert ['diced', 'quartered'] == static_subcommands_app.completion_matches
+
+
+complete_states_expected_self = None
+
+
+class WithCompleterCommandSet(cmd2.CommandSet):
+ states = ['alabama', 'alaska', 'arizona', 'arkansas', 'california', 'colorado', 'connecticut', 'delaware']
+
+ def __init__(self, dummy):
+ """dummy variable prevents this from being autoloaded in other tests"""
+ super(WithCompleterCommandSet, self).__init__()
+
+ def complete_states(self, cmd: cmd2.Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]:
+ assert self is complete_states_expected_self
+ return utils.basic_complete(text, line, begidx, endidx, self.states)
+
+
+class SubclassCommandSetCase1(WithCompleterCommandSet):
+ parser = cmd2.Cmd2ArgumentParser()
+ parser.add_argument('state', type=str, completer_method=WithCompleterCommandSet.complete_states)
+
+ @cmd2.with_argparser(parser)
+ def do_case1(self, cmd: cmd2.Cmd, ns: argparse.Namespace):
+ cmd.poutput('something {}'.format(ns.state))
+
+
+class SubclassCommandSetErrorCase2(WithCompleterCommandSet):
+ parser = cmd2.Cmd2ArgumentParser()
+ parser.add_argument('state', type=str, completer_method=WithCompleterCommandSet.complete_states)
+
+ @cmd2.with_argparser(parser)
+ def do_error2(self, cmd: cmd2.Cmd, ns: argparse.Namespace):
+ cmd.poutput('something {}'.format(ns.state))
+
+
+class SubclassCommandSetCase2(cmd2.CommandSet):
+ def __init__(self, dummy):
+ """dummy variable prevents this from being autoloaded in other tests"""
+ super(SubclassCommandSetCase2, self).__init__()
+
+ parser = cmd2.Cmd2ArgumentParser()
+ parser.add_argument('state', type=str, completer_method=WithCompleterCommandSet.complete_states)
+
+ @cmd2.with_argparser(parser)
+ def do_case2(self, cmd: cmd2.Cmd, ns: argparse.Namespace):
+ cmd.poutput('something {}'.format(ns.state))
+
+
+def test_cross_commandset_completer(command_sets_manual):
+ global complete_states_expected_self
+ # This tests the different ways to locate the matching CommandSet when completing an argparse argument.
+ # Exercises the `_complete_for_arg` function of `ArgparseCompleter` in `argparse_completer.py`
+
+ ####################################################################################################################
+ # This exercises Case 1
+ # If the CommandSet holding a command is a sub-class of the class that defines the completer function, then use that
+ # CommandSet instance as self when calling the completer
+ case1_set = SubclassCommandSetCase1(1)
+
+ command_sets_manual.install_command_set(case1_set)
+
+ text = ''
+ line = 'case1 {}'.format(text)
+ endidx = len(line)
+ begidx = endidx
+ complete_states_expected_self = case1_set
+ first_match = complete_tester(text, line, begidx, endidx, command_sets_manual)
+ complete_states_expected_self = None
+
+ assert first_match == 'alabama'
+ assert command_sets_manual.completion_matches == WithCompleterCommandSet.states
+
+ command_sets_manual.uninstall_command_set(case1_set)
+
+ ####################################################################################################################
+ # This exercises Case 2
+ # If the CommandSet holding a command is unrelated to the CommandSet holding the completer function, then search
+ # all installed CommandSet instances for one that is an exact type match
+
+ # First verify that, without the correct command set
+ base_set = WithCompleterCommandSet(1)
+ case2_set = SubclassCommandSetCase2(2)
+ command_sets_manual.install_command_set(base_set)
+ command_sets_manual.install_command_set(case2_set)
+
+ text = ''
+ line = 'case2 {}'.format(text)
+ endidx = len(line)
+ begidx = endidx
+ complete_states_expected_self = base_set
+ first_match = complete_tester(text, line, begidx, endidx, command_sets_manual)
+ complete_states_expected_self = None
+
+ assert first_match == 'alabama'
+ assert command_sets_manual.completion_matches == WithCompleterCommandSet.states
+
+ command_sets_manual.uninstall_command_set(case2_set)
+ command_sets_manual.uninstall_command_set(base_set)
+
+ ####################################################################################################################
+ # This exercises Case 3
+ # If the CommandSet holding a command is unrelated to the CommandSet holding the completer function,
+ # and no exact type match can be found, but sub-class matches can be found and there is only a single
+ # subclass match, then use the lone subclass match as the parent CommandSet.
+
+ command_sets_manual.install_command_set(case1_set)
+ command_sets_manual.install_command_set(case2_set)
+
+ text = ''
+ line = 'case2 {}'.format(text)
+ endidx = len(line)
+ begidx = endidx
+ complete_states_expected_self = case1_set
+ first_match = complete_tester(text, line, begidx, endidx, command_sets_manual)
+ complete_states_expected_self = None
+
+ assert first_match == 'alabama'
+ assert command_sets_manual.completion_matches == WithCompleterCommandSet.states
+
+ command_sets_manual.uninstall_command_set(case2_set)
+ command_sets_manual.uninstall_command_set(case1_set)
+
+ ####################################################################################################################
+ # Error Case 1
+ # If the CommandSet holding a command is unrelated to the CommandSet holding the completer function, then search
+ # all installed CommandSet instances for one that is an exact type match, none are found
+ # search for sub-class matches, also none are found.
+
+ command_sets_manual.install_command_set(case2_set)
+
+ text = ''
+ line = 'case2 {}'.format(text)
+ endidx = len(line)
+ begidx = endidx
+ first_match = complete_tester(text, line, begidx, endidx, command_sets_manual)
+
+ assert first_match is None
+ assert command_sets_manual.completion_matches == []
+
+ command_sets_manual.uninstall_command_set(case2_set)
+
+ ####################################################################################################################
+ # Error Case 2
+ # If the CommandSet holding a command is unrelated to the CommandSet holding the completer function, then search
+ # all installed CommandSet instances for one that is an exact type match, none are found
+ # search for sub-class matches, more than 1 is found
+
+ error_case2_set = SubclassCommandSetErrorCase2(4)
+ command_sets_manual.install_command_set(case1_set)
+ command_sets_manual.install_command_set(case2_set)
+ command_sets_manual.install_command_set(error_case2_set)
+
+ text = ''
+ line = 'case2 {}'.format(text)
+ endidx = len(line)
+ begidx = endidx
+ first_match = complete_tester(text, line, begidx, endidx, command_sets_manual)
+
+ assert first_match is None
+ assert command_sets_manual.completion_matches == []
+
+ command_sets_manual.uninstall_command_set(case2_set)
+
+
+class CommandSetWithPathComplete(cmd2.CommandSet):
+ def __init__(self, dummy):
+ """dummy variable prevents this from being autoloaded in other tests"""
+ super(CommandSetWithPathComplete, self).__init__()
+
+ parser = argparse.ArgumentParser()
+ parser.add_argument('path', nargs='+', help='paths', completer_method=cmd2.Cmd.path_complete)
+
+ @cmd2.with_argparser(parser)
+ def do_path(self, app: cmd2.Cmd, args):
+ app.poutput(args.path)
+
+
+def test_path_complete(command_sets_manual):
+ test_set = CommandSetWithPathComplete(1)
+
+ command_sets_manual.install_command_set(test_set)
+
+ text = ''
+ line = 'path {}'.format(text)
+ endidx = len(line)
+ begidx = endidx
+ first_match = complete_tester(text, line, begidx, endidx, command_sets_manual)
+
+ assert first_match is not None