Skip to content

Commit 62eccda

Browse files
committed
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
1 parent 2c99c0d commit 62eccda

File tree

13 files changed

+504
-149
lines changed

13 files changed

+504
-149
lines changed

cmd2/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@
3030
from .cmd2 import Cmd
3131
from .command_definition import CommandSet, with_default_category
3232
from .constants import COMMAND_NAME, DEFAULT_SHORTCUTS
33-
from .decorators import with_argument_list, with_argparser, with_argparser_and_unknown_args, with_category, as_subcommand_to
34-
from .exceptions import Cmd2ArgparseError, SkipPostcommandHooks
33+
from .decorators import with_argument_list, with_argparser, with_argparser_and_unknown_args, with_category, \
34+
as_subcommand_to
35+
from .exceptions import Cmd2ArgparseError, SkipPostcommandHooks, CommandSetRegistrationError
3536
from . import plugin
3637
from .parsing import Statement
3738
from .py_bridge import CommandResult

cmd2/argparse_completer.py

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
)
2626
from .command_definition import CommandSet
2727
from .table_creator import Column, SimpleTable
28-
from .utils import CompletionError, basic_complete
28+
from .utils import CompletionError, basic_complete, get_defining_class
2929

3030
# If no descriptive header is supplied, then this will be used instead
3131
DEFAULT_DESCRIPTIVE_HEADER = 'Description'
@@ -569,12 +569,43 @@ def _complete_for_arg(self, arg_action: argparse.Action,
569569
kwargs = {}
570570
if isinstance(arg_choices, ChoicesCallable):
571571
if arg_choices.is_method:
572-
cmd_set = getattr(self._parser, constants.PARSER_ATTR_COMMANDSET, cmd_set)
573-
if cmd_set is not None:
574-
if isinstance(cmd_set, CommandSet):
575-
# If command is part of a CommandSet, `self` should be the CommandSet and Cmd will be next
576-
if cmd_set is not None:
577-
args.append(cmd_set)
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)
578609
args.append(self._cmd2_app)
579610

580611
# Check if arg_choices.to_call expects arg_tokens

cmd2/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ class Cmd2ArgparseError(SkipPostcommandHooks):
2525

2626

2727
class CommandSetRegistrationError(Exception):
28+
"""
29+
Exception that can be thrown when an error occurs while a CommandSet is being added or removed
30+
from a cmd2 application.
31+
"""
2832
pass
2933

3034
############################################################################################################

cmd2/utils.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@
33

44
import collections
55
import collections.abc as collections_abc
6+
import functools
67
import glob
8+
import inspect
79
import os
810
import re
911
import subprocess
1012
import sys
1113
import threading
1214
import unicodedata
1315
from enum import Enum
14-
from typing import Any, Callable, Dict, Iterable, List, Optional, TextIO, Union
16+
from typing import Any, Callable, Dict, Iterable, List, Optional, TextIO, Type, Union
1517

1618
from . import constants
1719

@@ -1037,3 +1039,30 @@ def categorize(func: Union[Callable, Iterable[Callable]], category: str) -> None
10371039
setattr(item, constants.CMD_ATTR_HELP_CATEGORY, category)
10381040
else:
10391041
setattr(func, constants.CMD_ATTR_HELP_CATEGORY, category)
1042+
1043+
1044+
def get_defining_class(meth: Callable) -> Optional[Type]:
1045+
"""
1046+
Attempts to resolve the class that defined a method.
1047+
1048+
Inspired by implementation published here:
1049+
https://stackoverflow.com/a/25959545/1956611
1050+
1051+
:param meth: method to inspect
1052+
:return: class type in which the supplied method was defined. None if it couldn't be resolved.
1053+
"""
1054+
if isinstance(meth, functools.partial):
1055+
return get_defining_class(meth.func)
1056+
if inspect.ismethod(meth) or (inspect.isbuiltin(meth)
1057+
and getattr(meth, '__self__') is not None
1058+
and getattr(meth.__self__, '__class__')):
1059+
for cls in inspect.getmro(meth.__self__.__class__):
1060+
if meth.__name__ in cls.__dict__:
1061+
return cls
1062+
meth = getattr(meth, '__func__', meth) # fallback to __qualname__ parsing
1063+
if inspect.isfunction(meth):
1064+
cls = getattr(inspect.getmodule(meth),
1065+
meth.__qualname__.split('.<locals>', 1)[0].rsplit('.', 1)[0])
1066+
if isinstance(cls, type):
1067+
return cls
1068+
return getattr(meth, '__objclass__', None) # handle special descriptor objects

docs/api/exceptions.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,6 @@ Custom cmd2 exceptions
99

1010
.. autoclass:: cmd2.exceptions.Cmd2ArgparseError
1111
:members:
12+
13+
.. autoclass:: cmd2.exceptions.CommandSetRegistrationError
14+
:members:

docs/features/modular_commands.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ Cmd2 also enables developers to modularize their command definitions into Comman
88
a logical grouping of commands within an cmd2 application. By default, all CommandSets will be discovered and loaded
99
automatically when the cmd2.Cmd class is instantiated with this mixin. This also enables the developer to
1010
dynamically add/remove commands from the cmd2 application. This could be useful for loadable plugins that
11-
add additional capabilities.
11+
add additional capabilities. Additionally, it allows for object-oriented encapsulation and garbage collection of state
12+
that is specific to a CommandSet.
1213

1314
Features
1415
~~~~~~~~

tasks.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,10 @@ def pytest(context, junit=False, pty=True, base=False, isolated=False):
7070
tests_cmd = command_str + ' tests'
7171
context.run(tests_cmd, pty=pty)
7272
if isolated:
73-
for root, dirnames, _ in os.walk(str(TASK_ROOT/'isolated_tests')):
73+
for root, dirnames, _ in os.walk(str(TASK_ROOT/'tests_isolated')):
7474
for dir in dirnames:
7575
if dir.startswith('test_'):
76-
context.run(command_str + ' isolated_tests/' + dir)
76+
context.run(command_str + ' tests_isolated/' + dir)
7777

7878

7979
namespace.add_task(pytest)

tests/test_utils_defining_class.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# coding=utf-8
2+
# flake8: noqa E302
3+
"""
4+
Unit testing for get_defining_class in cmd2/utils.py module.
5+
"""
6+
import functools
7+
8+
import cmd2.utils as cu
9+
10+
11+
class ParentClass(object):
12+
def func_with_overrides(self):
13+
pass
14+
15+
def parent_only_func(self, param1, param2):
16+
pass
17+
18+
19+
class ChildClass(ParentClass):
20+
def func_with_overrides(self):
21+
super(ChildClass, self).func_with_overrides()
22+
23+
def child_function(self):
24+
pass
25+
26+
lambda1 = lambda: 1
27+
28+
lambda2 = (lambda: lambda: 2)()
29+
30+
@classmethod
31+
def class_method(cls):
32+
pass
33+
34+
@staticmethod
35+
def static_meth():
36+
pass
37+
38+
39+
def func_not_in_class():
40+
pass
41+
42+
43+
def test_get_defining_class():
44+
parent_instance = ParentClass()
45+
child_instance = ChildClass()
46+
47+
# validate unbound class functions
48+
assert cu.get_defining_class(ParentClass.func_with_overrides) is ParentClass
49+
assert cu.get_defining_class(ParentClass.parent_only_func) is ParentClass
50+
assert cu.get_defining_class(ChildClass.func_with_overrides) is ChildClass
51+
assert cu.get_defining_class(ChildClass.parent_only_func) is ParentClass
52+
assert cu.get_defining_class(ChildClass.child_function) is ChildClass
53+
assert cu.get_defining_class(ChildClass.class_method) is ChildClass
54+
assert cu.get_defining_class(ChildClass.static_meth) is ChildClass
55+
56+
# validate bound class methods
57+
assert cu.get_defining_class(parent_instance.func_with_overrides) is ParentClass
58+
assert cu.get_defining_class(parent_instance.parent_only_func) is ParentClass
59+
assert cu.get_defining_class(child_instance.func_with_overrides) is ChildClass
60+
assert cu.get_defining_class(child_instance.parent_only_func) is ParentClass
61+
assert cu.get_defining_class(child_instance.child_function) is ChildClass
62+
assert cu.get_defining_class(child_instance.class_method) is ChildClass
63+
assert cu.get_defining_class(child_instance.static_meth) is ChildClass
64+
65+
# bare functions resolve to nothing
66+
assert cu.get_defining_class(func_not_in_class) is None
67+
68+
# lambdas and nested lambdas
69+
assert cu.get_defining_class(ChildClass.lambda1) is ChildClass
70+
assert cu.get_defining_class(ChildClass.lambda2) is ChildClass
71+
assert cu.get_defining_class(ChildClass().lambda1) is ChildClass
72+
assert cu.get_defining_class(ChildClass().lambda2) is ChildClass
73+
74+
# partials
75+
partial_unbound = functools.partial(ParentClass.parent_only_func, 1)
76+
nested_partial_unbound = functools.partial(partial_unbound, 2)
77+
assert cu.get_defining_class(partial_unbound) is ParentClass
78+
assert cu.get_defining_class(nested_partial_unbound) is ParentClass
79+
80+
partial_bound = functools.partial(parent_instance.parent_only_func, 1)
81+
nested_partial_bound = functools.partial(partial_bound, 2)
82+
assert cu.get_defining_class(partial_bound) is ParentClass
83+
assert cu.get_defining_class(nested_partial_bound) is ParentClass
File renamed without changes.

isolated_tests/test_commandset/conftest.py renamed to tests_isolated/test_commandset/conftest.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from pytest import fixture
1111

1212
import cmd2
13+
from cmd2_ext_test import ExternalTestMixin
1314
from cmd2.utils import StdSim
1415

1516
# Prefer statically linked gnureadline if available (for macOS compatibility due to issues with libedit)
@@ -194,3 +195,21 @@ def get_endidx():
194195
with mock.patch.object(readline, 'get_begidx', get_begidx):
195196
with mock.patch.object(readline, 'get_endidx', get_endidx):
196197
return app.complete(text, 0)
198+
199+
200+
class WithCommandSets(ExternalTestMixin, cmd2.Cmd):
201+
"""Class for testing custom help_* methods which override docstring help."""
202+
def __init__(self, *args, **kwargs):
203+
super(WithCommandSets, self).__init__(*args, **kwargs)
204+
205+
206+
@fixture
207+
def command_sets_app():
208+
app = WithCommandSets()
209+
return app
210+
211+
212+
@fixture()
213+
def command_sets_manual():
214+
app = WithCommandSets(auto_load_commands=False)
215+
return app

0 commit comments

Comments
 (0)