summaryrefslogtreecommitdiffstats
path: root/src/lib/Bcfg2/Options/Subcommands.py
blob: 660bd50778e5f18128825a36752c0af51acc3c06 (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
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
""" Classes to make it easier to create commands with large numbers of
subcommands (e.g., bcfg2-admin, bcfg2-info). """

import re
import cmd
import sys
import copy
import shlex
import logging
from Bcfg2.Compat import StringIO
from Bcfg2.Options import PositionalArgument
from Bcfg2.Options.OptionGroups import Subparser
from Bcfg2.Options.Parser import Parser, setup as master_setup

__all__ = ["Subcommand", "HelpCommand", "CommandRegistry", "register_commands"]


class Subcommand(object):
    """ Base class for subcommands.  This must be subclassed to create
    commands.

    Specifically, you must override
    :func:`Bcfg2.Options.Subcommand.run`.  You may want to override:

    * The docstring, which will be used as the short help.
    * :attr:`Bcfg2.Options.Subcommand.options`
    * :attr:`Bcfg2.Options.Subcommand.help`
    * :attr:`Bcfg2.Options.Subcommand.interactive`
    *
    * :func:`Bcfg2.Options.Subcommand.shutdown`

    You should not need to override
    :func:`Bcfg2.Options.Subcommand.__call__` or
    :func:`Bcfg2.Options.Subcommand.usage`.

    A ``Subcommand`` subclass constructor must not take any arguments.
    """

    #: Options this command takes
    options = []

    #: Longer help message
    help = None

    #: Whether or not to expose this command in an interactive
    #: :class:`cmd.Cmd` shell, if one is used.  (``bcfg2-info`` uses
    #: one, ``bcfg2-admin`` does not.)
    interactive = True

    _ws_re = re.compile(r'\s+', flags=re.MULTILINE)

    def __init__(self):
        self.core = None
        description = "%s: %s" % (self.__class__.__name__.lower(),
                                  self.__class__.__doc__)

        #: The :class:`Bcfg2.Options.Parser` that will be used to
        #: parse options if this subcommand is called from an
        #: interactive :class:`cmd.Cmd` shell.
        self.parser = Parser(
            prog=self.__class__.__name__.lower(),
            description=description,
            components=[self],
            add_base_options=False,
            epilog=self.help)
        self._usage = None

        #: A :class:`logging.Logger` that can be used to produce
        #: logging output for this command.
        self.logger = logging.getLogger(self.__class__.__name__.lower())

    def __call__(self, args=None):
        """ Perform option parsing and other tasks necessary to
        support running ``Subcommand`` objects as part of a
        :class:`cmd.Cmd` shell.  You should not need to override
        ``__call__``.

        :param args: Arguments given in the interactive shell
        :type args: list of strings
        :returns: The return value of :func:`Bcfg2.Options.Subcommand.run`
        """
        if args is not None:
            self.parser.namespace = copy.copy(master_setup)
            alist = shlex.split(args)
            try:
                setup = self.parser.parse(alist)
            except SystemExit:
                return sys.exc_info()[1].code
            return self.run(setup)
        else:
            return self.run(master_setup)

    def usage(self):
        """ Get the short usage message. """
        if self._usage is None:
            sio = StringIO()
            self.parser.print_usage(file=sio)
            usage = self._ws_re.sub(' ', sio.getvalue()).strip()[7:]
            doc = self._ws_re.sub(' ', getattr(self, "__doc__")).strip()
            if doc is None:
                self._usage = usage
            else:
                self._usage = "%s - %s" % (usage, doc)
        return self._usage

    def run(self, setup):
        """ Run the command.

        :param setup: A namespace giving the options for this command.
                      This must be used instead of
                      :attr:`Bcfg2.Options.setup` because this command
                      may have been called from an interactive
                      :class:`cmd.Cmd` shell, and thus has its own
                      option parser and its own (private) namespace.
                      ``setup`` is guaranteed to contain all of the
                      options in the global
                      :attr:`Bcfg2.Options.setup` namespace, in
                      addition to any local options given to this
                      command from the interactive shell.
        :type setup: argparse.Namespace
        """
        raise NotImplementedError

    def shutdown(self):
        """ Perform any necessary shtudown tasks for this command This
        is called to when the program exits (*not* when this command
        is finished executing). """
        pass


class HelpCommand(Subcommand):
    """ Get help on a specific subcommand.  This must be subclassed to
    create the actual help command by overriding
    :func:`Bcfg2.Options.HelpCommand.command_registry` and giving the
    command access to a :class:`Bcfg2.Options.CommandRegistry`. """
    options = [PositionalArgument("command", nargs='?')]

    # the interactive shell has its own help
    interactive = False

    def command_registry(self):
        """ Return a :class:`Bcfg2.Options.CommandRegistry` class.
        All commands registered with the class will be included in the
        help message. """
        raise NotImplementedError

    def run(self, setup):
        commands = self.command_registry()
        if setup.command:
            try:
                commands[setup.command].parser.print_help()
                return 0
            except KeyError:
                print("No such command: %s" % setup.command)
        for command in sorted(commands.keys()):
            print(commands[command].usage())


class CommandRegistry(object):
    """ A ``CommandRegistry`` is used to register subcommands and
    provides a single interface to run them.  It's also used by
    :class:`Bcfg2.Options.HelpCommand` to produce help messages for
    all available commands. """

    #: A dict of registered commands.  Keys are the class names,
    #: lowercased (i.e., the command names), and values are instances
    #: of the command objects.
    commands = dict()

    options = []

    def runcommand(self):
        """ Run the single command named in
        ``Bcfg2.Options.setup.subcommand``, which is where
        :class:`Bcfg2.Options.Subparser` groups store the
        subcommand. """
        try:
            return self.commands[master_setup.subcommand].run(master_setup)
        finally:
            self.shutdown()

    def shutdown(self):
        """ Perform shutdown tasks.  This calls the ``shutdown``
        method of all registered subcommands. """
        self.commands[master_setup.subcommand].shutdown()

    @classmethod
    def register_command(cls, cmdcls):
        """ Register a single command.

        :param cmdcls: The command class to register
        :type cmdcls: type
        :returns: An instance of ``cmdcls``
        """
        cmd_obj = cmdcls()
        name = cmdcls.__name__.lower()
        cls.commands[name] = cmd_obj
        # py2.5 can't mix *magic and non-magical keyword args, thus
        # the **dict(...)
        cls.options.append(
            Subparser(*cmdcls.options, **dict(name=name, help=cmdcls.__doc__)))
        if issubclass(cls, cmd.Cmd) and cmdcls.interactive:
            setattr(cls, "do_%s" % name, cmd_obj)
            setattr(cls, "help_%s" % name, cmd_obj.parser.print_help)
        return cmd_obj


def register_commands(registry, candidates, parent=Subcommand):
    """ Register all subcommands in ``candidates`` against the
    :class:`Bcfg2.Options.CommandRegistry` subclass given in
    ``registry``.  A command is registered if and only if:

    * It is a subclass of the given ``parent`` (by default,
      :class:`Bcfg2.Options.Subcommand`);
    * It is not the parent class itself; and
    * Its name does not start with an underscore.

    :param registry: The :class:`Bcfg2.Options.CommandRegistry`
                     subclass against which commands will be
                     registered.
    :type registry: Bcfg2.Options.CommandRegistry
    :param candidates: A list of objects that will be considered for
                       registration.  Only objects that meet the
                       criteria listed above will be registered.
    :type candidates: list
    :param parent: Specify a parent class other than
                   :class:`Bcfg2.Options.Subcommand` that all
                   registered commands must subclass.
    :type parent: type
    """
    for attr in candidates:
        try:
            if (issubclass(attr, parent) and
                attr != parent and
                not attr.__name__.startswith("_")):
                registry.register_command(attr)
        except TypeError:
            pass