diff options
author | Eric Lin <anselor@gmail.com> | 2020-08-05 15:08:37 -0400 |
---|---|---|
committer | anselor <anselor@gmail.com> | 2020-08-06 16:01:19 -0400 |
commit | 62eccdac73d852d3ab9df06497bc8c9063e3d283 (patch) | |
tree | 2c7a02a8589270447d9ef611f0f6f170e5f0528f /cmd2 | |
parent | 2c99c0d9e7ddea1a93e97e3198aea01beca7c5d5 (diff) | |
download | cmd2-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 'cmd2')
-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 |
4 files changed, 75 insertions, 10 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 |