Skip to content

Commit 133e71a

Browse files
committed
When passing a ns_provider to an argparse command, will now attempt to resolve the correct CommandSet instance for self. If not, it'll fall back and pass in the cmd2 app
1 parent 774fb39 commit 133e71a

File tree

6 files changed

+96
-49
lines changed

6 files changed

+96
-49
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
* Added explicit testing against python 3.5.2 for Ubuntu 16.04, and 3.5.3 for Debian 9
99
* Added fallback definition of typing.Deque (taken from 3.5.4)
1010
* Removed explicit type hints that fail due to a bug in 3.5.2 favoring comment-based hints instead
11+
* When passing a ns_provider to an argparse command, will now attempt to resolve the correct
12+
CommandSet instance for self. If not, it'll fall back and pass in the cmd2 app
1113
* Other
1214
* Added missing doc-string for new cmd2.Cmd __init__ parameters
1315
introduced by CommandSet enhancement

cmd2/argparse_completer.py

Lines changed: 9 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -569,45 +569,15 @@ def _complete_for_arg(self, arg_action: argparse.Action,
569569
kwargs = {}
570570
if isinstance(arg_choices, ChoicesCallable):
571571
if arg_choices.is_method:
572-
# figure out what class the completer was defined in
573-
completer_class = get_defining_class(arg_choices.to_call)
574-
575-
# Was there a defining class identified? If so, is it a sub-class of CommandSet?
576-
if completer_class is not None and issubclass(completer_class, CommandSet):
577-
# Since the completer function is provided as an unbound function, we need to locate the instance
578-
# of the CommandSet to pass in as `self` to emulate a bound method call.
579-
# We're searching for candidates that match the completer function's parent type in this order:
580-
# 1. Does the CommandSet registered with the command's argparser match as a subclass?
581-
# 2. Do any of the registered CommandSets in the Cmd2 application exactly match the type?
582-
# 3. Is there a registered CommandSet that is is the only matching subclass?
583-
584-
# Now get the CommandSet associated with the current command/subcommand argparser
585-
parser_cmd_set = getattr(self._parser, constants.PARSER_ATTR_COMMANDSET, cmd_set)
586-
if isinstance(parser_cmd_set, completer_class):
587-
# Case 1: Parser's CommandSet is a sub-class of the completer function's CommandSet
588-
cmd_set = parser_cmd_set
589-
else:
590-
# Search all registered CommandSets
591-
cmd_set = None
592-
candidate_sets = [] # type: List[CommandSet]
593-
for installed_cmd_set in self._cmd2_app._installed_command_sets:
594-
if type(installed_cmd_set) == completer_class:
595-
# Case 2: CommandSet is an exact type match for the completer's CommandSet
596-
cmd_set = installed_cmd_set
597-
break
598-
599-
# Add candidate for Case 3:
600-
if isinstance(installed_cmd_set, completer_class):
601-
candidate_sets.append(installed_cmd_set)
602-
if cmd_set is None and len(candidate_sets) == 1:
603-
# Case 3: There exists exactly 1 CommandSet that is a subclass of the completer's CommandSet
604-
cmd_set = candidate_sets[0]
605-
if cmd_set is None:
606-
# No cases matched, raise an error
607-
raise CompletionError('Could not find CommandSet instance matching defining type for completer')
608-
args.append(cmd_set)
609-
else:
610-
args.append(self._cmd2_app)
572+
# The completer may or may not be defined in the same class as the command. Since completer
573+
# functions are registered with the command argparser before anything is instantiated, we
574+
# need to find an instance at runtime that matches the types during declaration
575+
cmd_set = self._cmd2_app._resolve_func_self(arg_choices.to_call, cmd_set)
576+
if cmd_set is None:
577+
# No cases matched, raise an error
578+
raise CompletionError('Could not find CommandSet instance matching defining type for completer')
579+
580+
args.append(cmd_set)
611581

612582
# Check if arg_choices.to_call expects arg_tokens
613583
to_call_params = inspect.signature(arg_choices.to_call).parameters

cmd2/cmd2.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
from .history import History, HistoryItem
6060
from .parsing import Macro, MacroArg, Statement, StatementParser, shlex_split
6161
from .rl_utils import RlType, rl_get_point, rl_make_safe_prompt, rl_set_prompt, rl_type, rl_warning, vt100_support
62-
from .utils import CompletionError, Settable
62+
from .utils import CompletionError, get_defining_class, Settable
6363

6464
# Set up readline
6565
if rl_type == RlType.NONE: # pragma: no cover
@@ -4656,3 +4656,51 @@ def register_cmdfinalization_hook(self, func: Callable[[plugin.CommandFinalizati
46564656
"""Register a hook to be called after a command is completed, whether it completes successfully or not."""
46574657
self._validate_cmdfinalization_callable(func)
46584658
self._cmdfinalization_hooks.append(func)
4659+
4660+
def _resolve_func_self(self,
4661+
cmd_support_func: Callable,
4662+
cmd_self: Union[CommandSet, 'Cmd']) -> object:
4663+
"""
4664+
Attempt to resolve a candidate instance to pass as 'self' for an unbound class method that was
4665+
used when defining command's argparse object. Since we restrict registration to only a single CommandSet
4666+
instance of each type, using type is a reasonably safe way to resolve the correct object instance
4667+
4668+
:param cmd_support_func: command support function. This could be a completer or namespace provider
4669+
:param cmd_self: The `self` associated with the command or sub-command
4670+
:return:
4671+
"""
4672+
# figure out what class the command support function was defined in
4673+
func_class = get_defining_class(cmd_support_func)
4674+
4675+
# Was there a defining class identified? If so, is it a sub-class of CommandSet?
4676+
if func_class is not None and issubclass(func_class, CommandSet):
4677+
# Since the support function is provided as an unbound function, we need to locate the instance
4678+
# of the CommandSet to pass in as `self` to emulate a bound method call.
4679+
# We're searching for candidates that match the support function's defining class type in this order:
4680+
# 1. Is the command's CommandSet a sub-class of the support function's class?
4681+
# 2. Do any of the registered CommandSets in the Cmd2 application exactly match the type?
4682+
# 3. Is there a registered CommandSet that is is the only matching subclass?
4683+
4684+
# check if the command's CommandSet is a sub-class of the support function's defining class
4685+
if isinstance(cmd_self, func_class):
4686+
# Case 1: Command's CommandSet is a sub-class of the support function's CommandSet
4687+
func_self = cmd_self
4688+
else:
4689+
# Search all registered CommandSets
4690+
func_self = None
4691+
candidate_sets = [] # type: List[CommandSet]
4692+
for installed_cmd_set in self._installed_command_sets:
4693+
if type(installed_cmd_set) == func_class:
4694+
# Case 2: CommandSet is an exact type match for the function's CommandSet
4695+
func_self = installed_cmd_set
4696+
break
4697+
4698+
# Add candidate for Case 3:
4699+
if isinstance(installed_cmd_set, func_class):
4700+
candidate_sets.append(installed_cmd_set)
4701+
if func_self is None and len(candidate_sets) == 1:
4702+
# Case 3: There exists exactly 1 CommandSet that is a sub-class match of the function's CommandSet
4703+
func_self = candidate_sets[0]
4704+
return func_self
4705+
else:
4706+
return self

cmd2/command_definition.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22
"""
33
Supports the definition of commands in separate classes to be composed into cmd2.Cmd
44
"""
5-
import functools
6-
from typing import Callable, Iterable, Optional, Type
5+
from typing import Optional, Type
76

87
from .constants import COMMAND_FUNC_PREFIX
98
from .exceptions import CommandSetRegistrationError

cmd2/decorators.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ def with_argument_list(*args: List[Callable], preserve_quotes: bool = False) ->
9494
>>> def do_echo(self, arglist):
9595
>>> self.poutput(' '.join(arglist)
9696
"""
97-
import functools, cmd2
97+
import functools
9898

9999
def arg_decorator(func: Callable):
100100
@functools.wraps(func)
@@ -282,7 +282,11 @@ def cmd_wrapper(*args: Any, **kwargs: Dict[str, Any]) -> Optional[bool]:
282282
if ns_provider is None:
283283
namespace = None
284284
else:
285-
namespace = ns_provider(cmd2_app)
285+
# The namespace provider may or may not be defined in the same class as the command. Since provider
286+
# functions are registered with the command argparser before anything is instantiated, we
287+
# need to find an instance at runtime that matches the types during declaration
288+
provider_self = cmd2_app._resolve_func_self(ns_provider, args[0])
289+
namespace = ns_provider(provider_self if not None else cmd2_app)
286290

287291
try:
288292
if with_unknown_args:

tests_isolated/test_commandset/test_commandset.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -261,17 +261,24 @@ class LoadableBase(cmd2.CommandSet):
261261
def __init__(self, dummy):
262262
super(LoadableBase, self).__init__()
263263
self._dummy = dummy # prevents autoload
264+
self._cut_called = False
264265

265266
cut_parser = cmd2.Cmd2ArgumentParser('cut')
266267
cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut')
267268

269+
def namespace_provider(self) -> argparse.Namespace:
270+
ns = argparse.Namespace()
271+
ns.cut_called = self._cut_called
272+
return ns
273+
268274
@cmd2.with_argparser(cut_parser)
269275
def do_cut(self, ns: argparse.Namespace):
270276
"""Cut something"""
271277
handler = ns.get_handler()
272278
if handler is not None:
273279
# Call whatever subcommand function was selected
274280
handler(ns)
281+
self._cut_called = True
275282
else:
276283
# No subcommand was provided, so call help
277284
self._cmd.pwarning('This command does nothing without sub-parsers registered')
@@ -281,9 +288,13 @@ def do_cut(self, ns: argparse.Namespace):
281288
stir_parser = cmd2.Cmd2ArgumentParser('stir')
282289
stir_subparsers = stir_parser.add_subparsers(title='item', help='what to stir')
283290

284-
@cmd2.with_argparser(stir_parser)
291+
@cmd2.with_argparser(stir_parser, ns_provider=namespace_provider)
285292
def do_stir(self, ns: argparse.Namespace):
286293
"""Stir something"""
294+
if not ns.cut_called:
295+
self._cmd.poutput('Need to cut before stirring')
296+
return
297+
287298
handler = ns.get_handler()
288299
if handler is not None:
289300
# Call whatever subcommand function was selected
@@ -371,8 +382,8 @@ def complete_style_arg(self, text: str, line: str, begidx: int, endidx: int) ->
371382
bokchoy_parser.add_argument('style', completer_method=complete_style_arg)
372383

373384
@cmd2.as_subcommand_to('cut', 'bokchoy', bokchoy_parser)
374-
def cut_bokchoy(self, _: cmd2.Statement):
375-
self._cmd.poutput('Bok Choy')
385+
def cut_bokchoy(self, ns: argparse.Namespace):
386+
self._cmd.poutput('Bok Choy: ' + ns.style)
376387

377388

378389
def test_subcommands(command_sets_manual):
@@ -498,8 +509,6 @@ def test_subcommands(command_sets_manual):
498509

499510
def test_nested_subcommands(command_sets_manual):
500511
base_cmds = LoadableBase(1)
501-
# fruit_cmds = LoadableFruits(1)
502-
# veg_cmds = LoadableVegetables(1)
503512
pasta_cmds = LoadablePastaStir(1)
504513

505514
with pytest.raises(CommandSetRegistrationError):
@@ -520,13 +529,28 @@ def __init__(self, dummy):
520529
stir_pasta_vigor_parser = cmd2.Cmd2ArgumentParser('vigor', add_help=False)
521530
stir_pasta_vigor_parser.add_argument('frequency')
522531

532+
# stir sauce doesn't exist anywhere, this should fail
523533
@cmd2.as_subcommand_to('stir sauce', 'vigorously', stir_pasta_vigor_parser)
524534
def stir_pasta_vigorously(self, ns: argparse.Namespace):
525535
self._cmd.poutput('stir the pasta vigorously')
526536

527537
with pytest.raises(CommandSetRegistrationError):
528538
command_sets_manual.register_command_set(BadNestedSubcommands(1))
529539

540+
fruit_cmds = LoadableFruits(1)
541+
command_sets_manual.register_command_set(fruit_cmds)
542+
543+
# validates custom namespace provider works correctly. Stir command will fail until
544+
# the cut command is called
545+
result = command_sets_manual.app_cmd('stir pasta vigorously everyminute')
546+
assert 'Need to cut before stirring' in result.stdout
547+
548+
result = command_sets_manual.app_cmd('cut banana discs')
549+
assert 'cutting banana: discs' in result.stdout
550+
551+
result = command_sets_manual.app_cmd('stir pasta vigorously everyminute')
552+
assert 'stir the pasta vigorously' in result.stdout
553+
530554

531555
class AppWithSubCommands(cmd2.Cmd):
532556
"""Class for testing usage of `as_subcommand_to` decorator directly in a Cmd2 subclass."""

0 commit comments

Comments
 (0)