summaryrefslogtreecommitdiff
path: root/cmd2/command_definition.py
blob: 9e7238d0939826daba7ab2950c084de927eaa93d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
# coding=utf-8
"""
Supports the definition of commands in separate classes to be composed into cmd2.Cmd
"""
from typing import (
    Dict,
    Mapping,
    Optional,
    Type,
)

from .constants import (
    CLASS_ATTR_DEFAULT_HELP_CATEGORY,
    COMMAND_FUNC_PREFIX,
)
from .exceptions import (
    CommandSetRegistrationError,
)
from .utils import (
    Settable,
)

# Allows IDEs to resolve types without impacting imports at runtime, breaking circular dependency issues
try:  # pragma: no cover
    from typing import (
        TYPE_CHECKING,
    )

    if TYPE_CHECKING:
        import cmd2

except ImportError:  # pragma: no cover
    pass


def with_default_category(category: str, *, heritable: bool = True):
    """
    Decorator that applies a category to all ``do_*`` command methods in a class that do not already
    have a category specified.

    CommandSets that are decorated by this with `heritable` set to True (default) will set a class attribute that is
    inherited by all subclasses unless overridden. All commands of this CommandSet and all subclasses of this CommandSet
    that do not declare an explicit category will be placed in this category. Subclasses may use this decorator to
    override the default category.

    If `heritable` is set to False, then only the commands declared locally to this CommandSet will be placed in the
    specified category. Dynamically created commands, and commands declared in sub-classes will not receive this
    category.

    :param category: category to put all uncategorized commands in
    :param heritable: Flag whether this default category should apply to sub-classes. Defaults to True
    :return: decorator function
    """

    def decorate_class(cls: Type[CommandSet]):
        if heritable:
            setattr(cls, CLASS_ATTR_DEFAULT_HELP_CATEGORY, category)

        import inspect

        from .constants import (
            CMD_ATTR_HELP_CATEGORY,
        )
        from .decorators import (
            with_category,
        )

        # get members of the class that meet the following criteria:
        # 1. Must be a function
        # 2. Must start with COMMAND_FUNC_PREFIX (do_)
        # 3. Must be a member of the class being decorated and not one inherited from a parent declaration
        methods = inspect.getmembers(
            cls,
            predicate=lambda meth: inspect.isfunction(meth)
            and meth.__name__.startswith(COMMAND_FUNC_PREFIX)
            and meth in inspect.getmro(cls)[0].__dict__.values(),
        )
        category_decorator = with_category(category)
        for method in methods:
            if not hasattr(method[1], CMD_ATTR_HELP_CATEGORY):
                setattr(cls, method[0], category_decorator(method[1]))
        return cls

    return decorate_class


class CommandSet(object):
    """
    Base class for defining sets of commands to load in cmd2.

    ``with_default_category`` can be used to apply a default category to all commands in the CommandSet.

    ``do_``, ``help_``, and ``complete_`` functions differ only in that self is the CommandSet instead of the cmd2 app
    """

    def __init__(self):
        self._cmd: Optional[cmd2.Cmd] = None
        self._settables: Dict[str, Settable] = {}
        self._settable_prefix = self.__class__.__name__

    def on_register(self, cmd) -> None:
        """
        Called by cmd2.Cmd as the first step to registering a CommandSet. The commands defined in this class have
        not be added to the CLI object at this point. Subclasses can override this to perform any initialization
        requiring access to the Cmd object (e.g. configure commands and their parsers based on CLI state data).

        :param cmd: The cmd2 main application
        :type cmd: cmd2.Cmd
        """
        if self._cmd is None:
            self._cmd = cmd
        else:
            raise CommandSetRegistrationError('This CommandSet has already been registered')

    def on_registered(self) -> None:
        """
        Called by cmd2.Cmd after a CommandSet is registered and all its commands have been added to the CLI.
        Subclasses can override this to perform custom steps related to the newly added commands (e.g. setting
        them to a disabled state).
        """
        pass

    def on_unregister(self) -> None:
        """
        Called by ``cmd2.Cmd`` as the first step to unregistering a CommandSet. Subclasses can override this to
        perform any cleanup steps which require their commands being registered in the CLI.
        """
        pass

    def on_unregistered(self) -> None:
        """
        Called by ``cmd2.Cmd`` after a CommandSet has been unregistered and all its commands removed from the CLI.
        Subclasses can override this to perform remaining cleanup steps.
        """
        self._cmd = None

    @property
    def settable_prefix(self) -> str:
        return self._settable_prefix

    @property
    def settables(self) -> Mapping[str, Settable]:
        return self._settables

    def add_settable(self, settable: Settable) -> None:
        """
        Convenience method to add a settable parameter to the CommandSet

        :param settable: Settable object being added
        """
        if self._cmd:
            if not self._cmd.always_prefix_settables:
                if settable.name in self._cmd.settables.keys() and settable.name not in self._settables.keys():
                    raise KeyError(f'Duplicate settable: {settable.name}')
            else:
                prefixed_name = f'{self._settable_prefix}.{settable.name}'
                if prefixed_name in self._cmd.settables.keys() and settable.name not in self._settables.keys():
                    raise KeyError(f'Duplicate settable: {settable.name}')
        self._settables[settable.name] = settable

    def remove_settable(self, name: str) -> None:
        """
        Convenience method for removing a settable parameter from the CommandSet

        :param name: name of the settable being removed
        :raises: KeyError if the Settable matches this name
        """
        try:
            del self._settables[name]
        except KeyError:
            raise KeyError(name + " is not a settable parameter")