File: subcommand.py

package info (click to toggle)
chromium 139.0.7258.127-1
  • links: PTS, VCS
  • area: main
  • in suites:
  • size: 6,122,068 kB
  • sloc: cpp: 35,100,771; ansic: 7,163,530; javascript: 4,103,002; python: 1,436,920; asm: 946,517; xml: 746,709; pascal: 187,653; perl: 88,691; sh: 88,436; objc: 79,953; sql: 51,488; cs: 44,583; fortran: 24,137; makefile: 22,147; tcl: 15,277; php: 13,980; yacc: 8,984; ruby: 7,485; awk: 3,720; lisp: 3,096; lex: 1,327; ada: 727; jsp: 228; sed: 36
file content (273 lines) | stat: -rw-r--r-- 10,503 bytes parent folder | download | duplicates (5)
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
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
# Copyright 2013 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Manages subcommands in a script.

Each subcommand should look like this:
  @usage('[pet name]')
  def CMDpet(parser, args):
    '''Prints a pet.

    Many people likes pet. This command prints a pet for your pleasure.
    '''
    parser.add_option('--color', help='color of your pet')
    options, args = parser.parse_args(args)
    if len(args) != 1:
      parser.error('A pet name is required')
    pet = args[0]
    if options.color:
      print('Nice %s %d' % (options.color, pet))
    else:
      print('Nice %s' % pet)
    return 0

Explanation:
  - usage decorator alters the 'usage: %prog' line in the command's help.
  - docstring is used to both short help line and long help line.
  - parser can be augmented with arguments.
  - return the exit code.
  - Every function in the specified module with a name starting with 'CMD' will
    be a subcommand.
  - The module's docstring will be used in the default 'help' page.
  - If a command has no docstring, it will not be listed in the 'help' page.
    Useful to keep compatibility commands around or aliases.
  - If a command is an alias to another one, it won't be documented. E.g.:
      CMDoldname = CMDnewcmd
    will result in oldname not being documented but supported and redirecting to
    newcmd. Make it a real function that calls the old function if you want it
    to be documented.
  - CMDfoo_bar will be command 'foo-bar'.
"""

import difflib
import sys
import textwrap
import optparse

from collections.abc import Callable
from typing import NoReturn

CommandFunction = Callable[[optparse.OptionParser, list[str]], int]


def usage(more: str) -> Callable[[CommandFunction], CommandFunction]:
    """Adds a 'usage_more' property to a CMD function."""
    def hook(fn):
        fn.usage_more = more
        return fn

    return hook


def epilog(text: str) -> Callable[[CommandFunction], CommandFunction]:
    """Adds an 'epilog' property to a CMD function.

    It will be shown in the epilog. Usually useful for examples.
    """
    def hook(fn):
        fn.epilog = text
        return fn

    return hook


def CMDhelp(parser: optparse.OptionParser, args: list[str]) -> NoReturn:
    """Prints list of commands or help for a specific command."""
    # This is the default help implementation. It can be disabled or overridden
    # if wanted.
    if not any(i in ('-h', '--help') for i in args):
        args = args + ['--help']
    parser.parse_args(args)
    # Never gets there.
    assert False


def _get_color_module():
    """Returns the colorama module if available.

    If so, assumes colors are supported and return the module handle.
    """
    return sys.modules.get('colorama') or sys.modules.get(
        'third_party.colorama')


def _function_to_name(name: str) -> str:
    """Returns the name of a CMD function."""
    return name[3:].replace('_', '-')


class CommandDispatcher(object):

    def __init__(self, module: str):
        """module is the name of the main python module where to look for
        commands.

        The python builtin variable __name__ MUST be used for |module|. If the
        script is executed in the form 'python script.py',
        __name__ == '__main__' and sys.modules['script'] doesn't exist. On the
        other hand if it is unit tested, __main__ will be the unit test's
        module so it has to reference to itself with 'script'. __name__ always
        match the right value.
        """
        self.module = sys.modules[module]

    def enumerate_commands(self) -> dict[str, CommandFunction]:
        """Returns a dict of command and their handling function.

        The commands must be in the '__main__' modules. To import a command
        from a submodule, use:
            from mysubcommand import CMDfoo

        Automatically adds 'help' if not already defined.

        Normalizes '_' in the commands to '-'.

        A command can be effectively disabled by defining a global variable to
        None, e.g.:
            CMDhelp = None
        """
        cmds = dict((_function_to_name(name), getattr(self.module, name))
                    for name in dir(self.module) if name.startswith('CMD'))
        cmds.setdefault('help', CMDhelp)
        return cmds

    def find_nearest_command(self, name_asked: str) -> CommandFunction | None:
        """Retrieves the function to handle a command as supplied by the user.

        It automatically tries to guess the _intended command_ by handling typos
        and/or incomplete names.
        """
        commands = self.enumerate_commands()
        name_to_dash = name_asked.replace('_', '-')
        if name_to_dash in commands:
            return commands[name_to_dash]

        # An exact match was not found. Try to be smart and look if there's
        # something similar.
        commands_with_prefix = [c for c in commands if c.startswith(name_asked)]
        if len(commands_with_prefix) == 1:
            return commands[commands_with_prefix[0]]

        # A #closeenough approximation of levenshtein distance.
        def close_enough(a, b):
            return difflib.SequenceMatcher(a=a, b=b).ratio()

        hamming_commands = sorted(
            ((close_enough(c, name_asked), c) for c in commands), reverse=True)
        if (hamming_commands[0][0] - hamming_commands[1][0]) < 0.3:
            # Too ambiguous.
            return None

        if hamming_commands[0][0] < 0.8:
            # Not similar enough. Don't be a fool and run a random command.
            return None

        return commands[hamming_commands[0][1]]

    def _gen_commands_list(self) -> str:
        """Generates the short list of supported commands."""
        commands = self.enumerate_commands()
        docs = sorted(
            (cmd_name, self._create_command_summary(cmd_name, handler))
            for cmd_name, handler in commands.items())
        # Skip commands without a docstring.
        docs = [i for i in docs if i[1]]
        # Then calculate maximum length for alignment:
        length = max(len(c) for c in commands)

        # Look if color is supported.
        colors = _get_color_module()
        green = reset = ''
        if colors:
            green = colors.Fore.GREEN
            reset = colors.Fore.RESET
        return ('Commands are:\n' +
                ''.join('  %s%-*s%s %s\n' %
                        (green, length, cmd_name, reset, doc)
                        for cmd_name, doc in docs))

    def _add_command_usage(self, parser: optparse.OptionParser,
                           command: CommandFunction) -> None:
        """Modifies an OptionParser object with the function's documentation."""
        cmd_name = _function_to_name(command.__name__)
        if cmd_name == 'help':
            cmd_name = '<command>'
            # Use the module's docstring as the description for the 'help'
            # command if available.
            parser.description = (self.module.__doc__ or '').rstrip()
            if parser.description:
                parser.description += '\n\n'
            parser.description += self._gen_commands_list()
            # Do not touch epilog.
        else:
            # Use the command's docstring if available. For commands, unlike
            # module docstring, realign.
            lines = (command.__doc__ or '').rstrip().splitlines()
            if lines[:1]:
                rest = textwrap.dedent('\n'.join(lines[1:]))
                parser.description = '\n'.join((lines[0], rest))
            else:
                parser.description = lines[0] if lines else ''
            if parser.description:
                parser.description += '\n'
            parser.epilog = getattr(command, 'epilog', None)
            if parser.epilog:
                parser.epilog = '\n' + parser.epilog.strip() + '\n'

        more = getattr(command, 'usage_more', '')
        extra = '' if not more else ' ' + more
        parser.set_usage('usage: %%prog %s [options]%s' % (cmd_name, extra))

    @staticmethod
    def _create_command_summary(cmd_name: str, command: CommandFunction) -> str:
        """Creates a oneliner summary from the command's docstring."""
        if cmd_name != _function_to_name(command.__name__):
            # Skip aliases. For example using at module level:
            # CMDfoo = CMDbar
            return ''
        doc = command.__doc__ or ''
        line = doc.split('\n', 1)[0].rstrip('.')
        if not line:
            return line
        return (line[0].lower() + line[1:]).strip()

    def execute(self, parser: optparse.OptionParser, args: list[str]) -> int:
        """Dispatches execution to the right command.

        Fallbacks to 'help' if not disabled.
        """
        # Unconditionally disable format_description() and format_epilog().
        # Technically, a formatter should be used but it's not worth (yet) the
        # trouble.
        parser.format_description = lambda formatter: parser.description or ''
        parser.format_epilog = lambda formatter: parser.epilog or ''

        if args:
            if args[0] in ('-h', '--help') and len(args) > 1:
                # Reverse the argument order so 'tool --help cmd' is rewritten
                # to 'tool cmd --help'.
                args = [args[1], args[0]] + args[2:]
            command = self.find_nearest_command(args[0])
            if command:
                if command.__name__ == 'CMDhelp' and len(args) > 1:
                    # Reverse the argument order so 'tool help cmd' is rewritten
                    # to 'tool cmd --help'. Do it here since we want 'tool help
                    # cmd' to work too.
                    args = [args[1], '--help'] + args[2:]
                    command = self.find_nearest_command(args[0]) or command

                # "fix" the usage and the description now that we know the
                # subcommand.
                self._add_command_usage(parser, command)
                return command(parser, args[1:])

        cmdhelp = self.enumerate_commands().get('help')
        if cmdhelp:
            # Not a known command. Default to help.
            self._add_command_usage(parser, cmdhelp)
            # Don't pass list of arguments as those may not be supported by
            # cmdhelp. See: https://crbug.com/1352093
            return cmdhelp(parser, [])

        # Nothing can be done.
        return 2