diff options
-rw-r--r-- | cmd2/__init__.py | 5 | ||||
-rw-r--r-- | cmd2/argparse_completer.py | 45 | ||||
-rw-r--r-- | cmd2/exceptions.py | 4 | ||||
-rw-r--r-- | cmd2/utils.py | 31 | ||||
-rw-r--r-- | docs/api/exceptions.rst | 3 | ||||
-rw-r--r-- | docs/features/modular_commands.rst | 3 | ||||
-rw-r--r-- | tasks.py | 4 | ||||
-rw-r--r-- | tests/test_utils_defining_class.py | 83 | ||||
-rw-r--r-- | tests_isolated/__init__.py (renamed from isolated_tests/__init__.py) | 0 | ||||
-rw-r--r-- | tests_isolated/test_commandset/__init__.py (renamed from isolated_tests/test_commandset/__init__.py) | 0 | ||||
-rw-r--r-- | tests_isolated/test_commandset/conftest.py (renamed from isolated_tests/test_commandset/conftest.py) | 19 | ||||
-rw-r--r-- | tests_isolated/test_commandset/test_argparse_subcommands.py | 138 | ||||
-rw-r--r-- | tests_isolated/test_commandset/test_commandset.py (renamed from isolated_tests/test_commandset/test_commandset.py) | 318 |
13 files changed, 504 insertions, 149 deletions
diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 19e620be..9f0bb176 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -30,8 +30,9 @@ from .argparse_custom import DEFAULT_ARGUMENT_PARSER from .cmd2 import Cmd from .command_definition import CommandSet, with_default_category from .constants import COMMAND_NAME, DEFAULT_SHORTCUTS -from .decorators import with_argument_list, with_argparser, with_argparser_and_unknown_args, with_category, as_subcommand_to -from .exceptions import Cmd2ArgparseError, SkipPostcommandHooks +from .decorators import with_argument_list, with_argparser, with_argparser_and_unknown_args, with_category, \ + as_subcommand_to +from .exceptions import Cmd2ArgparseError, SkipPostcommandHooks, CommandSetRegistrationError from . import plugin from .parsing import Statement from .py_bridge import CommandResult diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 0225d22f..f14e83fd 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -25,7 +25,7 @@ from .argparse_custom import ( ) from .command_definition import CommandSet from .table_creator import Column, SimpleTable -from .utils import CompletionError, basic_complete +from .utils import CompletionError, basic_complete, get_defining_class # If no descriptive header is supplied, then this will be used instead DEFAULT_DESCRIPTIVE_HEADER = 'Description' @@ -569,12 +569,43 @@ class ArgparseCompleter: kwargs = {} if isinstance(arg_choices, ChoicesCallable): if arg_choices.is_method: - cmd_set = getattr(self._parser, constants.PARSER_ATTR_COMMANDSET, cmd_set) - if cmd_set is not None: - if isinstance(cmd_set, CommandSet): - # If command is part of a CommandSet, `self` should be the CommandSet and Cmd will be next - if cmd_set is not None: - args.append(cmd_set) + # figure out what class the completer was defined in + completer_class = get_defining_class(arg_choices.to_call) + + # Was there a defining class identified? If so, is it a sub-class of CommandSet? + if completer_class is not None and issubclass(completer_class, CommandSet): + # Since the completer function is provided as an unbound function, we need to locate the instance + # of the CommandSet to pass in as `self` to emulate a bound method call. + # We're searching for candidates that match the completer function's parent type in this order: + # 1. Does the CommandSet registered with the command's argparser match as a subclass? + # 2. Do any of the registered CommandSets in the Cmd2 application exactly match the type? + # 3. Is there a registered CommandSet that is is the only matching subclass? + + # Now get the CommandSet associated with the current command/subcommand argparser + parser_cmd_set = getattr(self._parser, constants.PARSER_ATTR_COMMANDSET, cmd_set) + if isinstance(parser_cmd_set, completer_class): + # Case 1: Parser's CommandSet is a sub-class of the completer function's CommandSet + cmd_set = parser_cmd_set + else: + # Search all registered CommandSets + cmd_set = None + candidate_sets = [] # type: List[CommandSet] + for installed_cmd_set in self._cmd2_app._installed_command_sets: + if type(installed_cmd_set) == completer_class: + # Case 2: CommandSet is an exact type match for the completer's CommandSet + cmd_set = installed_cmd_set + break + + # Add candidate for Case 3: + if isinstance(installed_cmd_set, completer_class): + candidate_sets.append(installed_cmd_set) + if cmd_set is None and len(candidate_sets) == 1: + # Case 3: There exists exactly 1 CommandSet that is a subclass of the completer's CommandSet + cmd_set = candidate_sets[0] + if cmd_set is None: + # No cases matched, raise an error + raise CompletionError('Could not find CommandSet instance matching defining type for completer') + args.append(cmd_set) args.append(self._cmd2_app) # Check if arg_choices.to_call expects arg_tokens diff --git a/cmd2/exceptions.py b/cmd2/exceptions.py index b928f293..d253985a 100644 --- a/cmd2/exceptions.py +++ b/cmd2/exceptions.py @@ -25,6 +25,10 @@ class Cmd2ArgparseError(SkipPostcommandHooks): class CommandSetRegistrationError(Exception): + """ + Exception that can be thrown when an error occurs while a CommandSet is being added or removed + from a cmd2 application. + """ pass ############################################################################################################ diff --git a/cmd2/utils.py b/cmd2/utils.py index 5a4fdbf7..39dc6e2b 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -3,7 +3,9 @@ import collections import collections.abc as collections_abc +import functools import glob +import inspect import os import re import subprocess @@ -11,7 +13,7 @@ import sys import threading import unicodedata from enum import Enum -from typing import Any, Callable, Dict, Iterable, List, Optional, TextIO, Union +from typing import Any, Callable, Dict, Iterable, List, Optional, TextIO, Type, Union from . import constants @@ -1037,3 +1039,30 @@ def categorize(func: Union[Callable, Iterable[Callable]], category: str) -> None setattr(item, constants.CMD_ATTR_HELP_CATEGORY, category) else: setattr(func, constants.CMD_ATTR_HELP_CATEGORY, category) + + +def get_defining_class(meth: Callable) -> Optional[Type]: + """ + Attempts to resolve the class that defined a method. + + Inspired by implementation published here: + https://stackoverflow.com/a/25959545/1956611 + + :param meth: method to inspect + :return: class type in which the supplied method was defined. None if it couldn't be resolved. + """ + if isinstance(meth, functools.partial): + return get_defining_class(meth.func) + if inspect.ismethod(meth) or (inspect.isbuiltin(meth) + and getattr(meth, '__self__') is not None + and getattr(meth.__self__, '__class__')): + for cls in inspect.getmro(meth.__self__.__class__): + if meth.__name__ in cls.__dict__: + return cls + meth = getattr(meth, '__func__', meth) # fallback to __qualname__ parsing + if inspect.isfunction(meth): + cls = getattr(inspect.getmodule(meth), + meth.__qualname__.split('.<locals>', 1)[0].rsplit('.', 1)[0]) + if isinstance(cls, type): + return cls + return getattr(meth, '__objclass__', None) # handle special descriptor objects diff --git a/docs/api/exceptions.rst b/docs/api/exceptions.rst index 8ef0a61f..db23eb0a 100644 --- a/docs/api/exceptions.rst +++ b/docs/api/exceptions.rst @@ -9,3 +9,6 @@ Custom cmd2 exceptions .. autoclass:: cmd2.exceptions.Cmd2ArgparseError :members: + +.. autoclass:: cmd2.exceptions.CommandSetRegistrationError + :members: diff --git a/docs/features/modular_commands.rst b/docs/features/modular_commands.rst index 9823d3ac..d19c3b45 100644 --- a/docs/features/modular_commands.rst +++ b/docs/features/modular_commands.rst @@ -8,7 +8,8 @@ Cmd2 also enables developers to modularize their command definitions into Comman a logical grouping of commands within an cmd2 application. By default, all CommandSets will be discovered and loaded automatically when the cmd2.Cmd class is instantiated with this mixin. This also enables the developer to dynamically add/remove commands from the cmd2 application. This could be useful for loadable plugins that -add additional capabilities. +add additional capabilities. Additionally, it allows for object-oriented encapsulation and garbage collection of state +that is specific to a CommandSet. Features ~~~~~~~~ @@ -70,10 +70,10 @@ def pytest(context, junit=False, pty=True, base=False, isolated=False): tests_cmd = command_str + ' tests' context.run(tests_cmd, pty=pty) if isolated: - for root, dirnames, _ in os.walk(str(TASK_ROOT/'isolated_tests')): + for root, dirnames, _ in os.walk(str(TASK_ROOT/'tests_isolated')): for dir in dirnames: if dir.startswith('test_'): - context.run(command_str + ' isolated_tests/' + dir) + context.run(command_str + ' tests_isolated/' + dir) namespace.add_task(pytest) diff --git a/tests/test_utils_defining_class.py b/tests/test_utils_defining_class.py new file mode 100644 index 00000000..0fbcf83b --- /dev/null +++ b/tests/test_utils_defining_class.py @@ -0,0 +1,83 @@ +# coding=utf-8 +# flake8: noqa E302 +""" +Unit testing for get_defining_class in cmd2/utils.py module. +""" +import functools + +import cmd2.utils as cu + + +class ParentClass(object): + def func_with_overrides(self): + pass + + def parent_only_func(self, param1, param2): + pass + + +class ChildClass(ParentClass): + def func_with_overrides(self): + super(ChildClass, self).func_with_overrides() + + def child_function(self): + pass + + lambda1 = lambda: 1 + + lambda2 = (lambda: lambda: 2)() + + @classmethod + def class_method(cls): + pass + + @staticmethod + def static_meth(): + pass + + +def func_not_in_class(): + pass + + +def test_get_defining_class(): + parent_instance = ParentClass() + child_instance = ChildClass() + + # validate unbound class functions + assert cu.get_defining_class(ParentClass.func_with_overrides) is ParentClass + assert cu.get_defining_class(ParentClass.parent_only_func) is ParentClass + assert cu.get_defining_class(ChildClass.func_with_overrides) is ChildClass + assert cu.get_defining_class(ChildClass.parent_only_func) is ParentClass + assert cu.get_defining_class(ChildClass.child_function) is ChildClass + assert cu.get_defining_class(ChildClass.class_method) is ChildClass + assert cu.get_defining_class(ChildClass.static_meth) is ChildClass + + # validate bound class methods + assert cu.get_defining_class(parent_instance.func_with_overrides) is ParentClass + assert cu.get_defining_class(parent_instance.parent_only_func) is ParentClass + assert cu.get_defining_class(child_instance.func_with_overrides) is ChildClass + assert cu.get_defining_class(child_instance.parent_only_func) is ParentClass + assert cu.get_defining_class(child_instance.child_function) is ChildClass + assert cu.get_defining_class(child_instance.class_method) is ChildClass + assert cu.get_defining_class(child_instance.static_meth) is ChildClass + + # bare functions resolve to nothing + assert cu.get_defining_class(func_not_in_class) is None + + # lambdas and nested lambdas + assert cu.get_defining_class(ChildClass.lambda1) is ChildClass + assert cu.get_defining_class(ChildClass.lambda2) is ChildClass + assert cu.get_defining_class(ChildClass().lambda1) is ChildClass + assert cu.get_defining_class(ChildClass().lambda2) is ChildClass + + # partials + partial_unbound = functools.partial(ParentClass.parent_only_func, 1) + nested_partial_unbound = functools.partial(partial_unbound, 2) + assert cu.get_defining_class(partial_unbound) is ParentClass + assert cu.get_defining_class(nested_partial_unbound) is ParentClass + + partial_bound = functools.partial(parent_instance.parent_only_func, 1) + nested_partial_bound = functools.partial(partial_bound, 2) + assert cu.get_defining_class(partial_bound) is ParentClass + assert cu.get_defining_class(nested_partial_bound) is ParentClass diff --git a/isolated_tests/__init__.py b/tests_isolated/__init__.py index e69de29b..e69de29b 100644 --- a/isolated_tests/__init__.py +++ b/tests_isolated/__init__.py diff --git a/isolated_tests/test_commandset/__init__.py b/tests_isolated/test_commandset/__init__.py index 037f3866..037f3866 100644 --- a/isolated_tests/test_commandset/__init__.py +++ b/tests_isolated/test_commandset/__init__.py diff --git a/isolated_tests/test_commandset/conftest.py b/tests_isolated/test_commandset/conftest.py index 5b1a6f05..037be199 100644 --- a/isolated_tests/test_commandset/conftest.py +++ b/tests_isolated/test_commandset/conftest.py @@ -10,6 +10,7 @@ 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) @@ -194,3 +195,21 @@ def complete_tester(text: str, line: str, begidx: int, endidx: int, app) -> Opti 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/isolated_tests/test_commandset/test_commandset.py b/tests_isolated/test_commandset/test_commandset.py index 83ae4646..506b309d 100644 --- a/isolated_tests/test_commandset/test_commandset.py +++ b/tests_isolated/test_commandset/test_commandset.py @@ -11,8 +11,7 @@ import pytest import cmd2 from cmd2 import utils -from cmd2_ext_test import ExternalTestMixin -from .conftest import complete_tester, run_cmd +from .conftest import complete_tester, WithCommandSets from cmd2.exceptions import CommandSetRegistrationError @@ -77,24 +76,6 @@ class CommandSetB(cmd2.CommandSet): cmd.poutput('Crocodile!!') -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) - - -@pytest.fixture -def command_sets_app(): - app = WithCommandSets() - return app - - -@pytest.fixture() -def command_sets_manual(): - app = WithCommandSets(auto_load_commands=False) - return app - - def test_autoload_commands(command_sets_app): # verifies that, when autoload is enabled, CommandSets and registered functions all show up @@ -542,7 +523,7 @@ def test_nested_subcommands(command_sets_manual): class AppWithSubCommands(cmd2.Cmd): - """Class for testing custom help_* methods which override docstring help.""" + """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) @@ -611,125 +592,190 @@ def test_static_subcommands(static_subcommands_app): assert ['diced', 'quartered'] == static_subcommands_app.completion_matches -# reproduces test_argparse.py except with SubCommands -class SubcommandSet(cmd2.CommandSet): - """ Example cmd2 application where we a base command which has a couple subcommands.""" +complete_states_expected_self = None + + +class WithCompleterCommandSet(cmd2.CommandSet): + states = ['alabama', 'alaska', 'arizona', 'arkansas', 'california', 'colorado', 'connecticut', 'delaware'] 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) + """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) -@pytest.fixture -def subcommand_app(): - app = WithCommandSets(auto_load_commands=False, - command_sets=[SubcommandSet(1)]) - return app +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 -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') + 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 |