• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

aperezdc / python-cmdcmd / 6815417206

09 Nov 2023 05:36PM UTC coverage: 47.903%. Remained the same
6815417206

Pull #2

github

web-flow
Merge 10eb6d47f into 0ee26278b
Pull Request #2: Test Python 3.11 instead of 3.10

65 of 230 branches covered (0.0%)

217 of 453 relevant lines covered (47.9%)

0.48 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

47.67
/src/cmdcmd/cmd.py
1
# -*- coding: utf-8 -*-
2
#
3
# Copyright © 2017 Adrian Perez <aperez@igalia.com>
4
# Copyright © 2010 Igalia S.L.
5
#
6
# Distributed under terms of the MIT license.
7

8
"""
1✔
9
Utilities to build command-line action-based programs.
10
"""
11

12
import optparse
1✔
13
import os
1✔
14
import sys
1✔
15
import types
1✔
16
from enum import Enum
1✔
17
from inspect import isclass
1✔
18
from keyword import iskeyword
1✔
19

20

21
def rst_to_plain_text(text):
1✔
22
    """Minimal converter of reStructuredText to plain text.
23

24
    Will work at least for command descriptions.
25

26
    :param text: Text to be converted.
27
    :rtype: str
28
    """
29
    import re
×
30

31
    lines = text.splitlines()
×
32
    result = []
×
33
    for line in lines:
×
34
        if line.startswith(":"):
×
35
            line = line[1:]
×
36
        elif line.endswith("::"):
×
37
            line = line[:-1]
×
38
        # Map :doc:`xxx-help` to ``help xxx``
39
        line = re.sub(":doc:`(.+)-help`", r"``help \1``", line)
×
40
        result.append(line)
×
41
    return "\n".join(result) + "\n"
×
42

43

44
class CommandError(RuntimeError):
1✔
45
    """An error occured running a command."""
46

47

48
class Option:
1✔
49
    """Describes a command line option.
50

51
    :ivar _short_name: If the option has a single-letter alias, this is
52
        defined. Otherwise this is None.
53
    """
54

55
    STD_OPTIONS = {}
1✔
56

57
    OPTIONS = {}
1✔
58

59
    def __init__(
1✔
60
        self,
61
        name,
62
        help="",  # noqa: A002
63
        type=None,  # noqa: A002
64
        argname=None,
65
        short_name=None,
66
        param_name=None,
67
        custom_callback=None,
68
        hidden=False,
69
    ):
70
        """Make a new action option.
71

72
        :param name: regular name of the command, used in the double-dash
73
            form and also as the parameter to the command's run()
74
            method (unless param_name is specified).
75

76
        :param help: help message displayed in command help
77

78
        :param type: type of the option argument, which must be callable
79
            with a string value to convert a textual representation into
80
            a value of the type, or None (default) if this option doesn't
81
            take an argument.
82

83
        :param argname: name of the option argument, if any. Must be ``None``
84
            for options that do not take arguments. If not specified, the
85
            default is ``"ARG"``, or the name of the class if the `type` is
86
            an ``Enum``. The argument name is always converted to uppercase
87
            automatically.
88

89
        :param short_name: short option code for use with a single -, e.g.
90
            short_name="v" to enable parsing of -v.
91

92
        :param param_name: name of the parameter which will be passed to
93
            the command's run() method.
94

95
        :param custom_callback: a callback routine to be called after normal
96
            processing. The signature of the callback routine is
97
            (option, name, new_value, parser).
98
        :param hidden: If True, the option should be hidden in help and
99
            documentation.
100

101
        Using ``Enum`` subtypes is supported:
102

103
        >>> from cmdcmd import Option
104
        >>> from enum import Enum
105
        >>>
106
        >>> class Protocol(Enum):
107
        ...     TCP = "tcp"
108
        ...     UDP = "udp"
109
        ...
110
        >>> Option("protocol", type=Protocol)
111
        <cmdcmd.cmd.Option object at 0x...>
112
        >>>
113
        """
114
        self.name = name
1✔
115
        self.help = help
1✔
116
        self.type = type
1✔
117
        self._short_name = short_name
1✔
118
        if type is None:
1✔
119
            if argname:
1!
120
                raise ValueError("argname not valid for booleans")
×
121
        else:
122
            if argname is None:
1!
123
                argname = type.__name__ if issubclass(type, Enum) else "ARG"
1✔
124
            argname = argname.upper()
1✔
125
        self.argname = argname
1✔
126
        if param_name is None:
1!
127
            self._param_name = self.name.replace("-", "_")
1✔
128
        else:
129
            self._param_name = param_name
×
130
        self.custom_callback = custom_callback
1✔
131
        self.hidden = hidden
1✔
132

133
    def get_short_name(self):
1✔
134
        """Return the short name of the option."""
135
        if self._short_name:
1✔
136
            return self._short_name
1✔
137

138
    def set_short_name(self, short_name):
1✔
139
        """Set the short name of the option."""
140
        self._short_name = short_name
×
141

142
    short_name = property(
1✔
143
        get_short_name, set_short_name, doc="Short name of the option"
144
    )
145

146
    def get_negation_name(self):
1✔
147
        """Return the negated name of the option."""
148
        if self.name.startswith("no-"):
1!
149
            return self.name[3:]
×
150
        else:
151
            return "no-" + self.name
1✔
152

153
    negation_name = property(get_negation_name, doc="Negated name of the option")
1✔
154

155
    def add_option(self, parser, short_name):
1✔
156
        """Add this option to an optparse parser."""
157
        option_strings = ["--" + self.name]
1✔
158
        if short_name is not None:
1✔
159
            option_strings.append("-" + short_name)
1✔
160
        if self.hidden:
1!
161
            _help = optparse.SUPPRESS_HELP
×
162
        else:
163
            _help = self.help
1✔
164
        optargfn = self.type
1✔
165
        if optargfn is None:
1✔
166
            parser.add_option(
1✔
167
                action="callback",
168
                callback=self._optparse_bool_callback,
169
                callback_args=(True,),
170
                help=_help,
171
                *option_strings
172
            )
173
            negation_strings = ["--" + self.get_negation_name()]
1✔
174
            parser.add_option(
1✔
175
                action="callback",
176
                callback=self._optparse_bool_callback,
177
                callback_args=(False,),
178
                help=optparse.SUPPRESS_HELP,
179
                *negation_strings
180
            )
181
        elif issubclass(optargfn, Enum):
1!
182
            values = (m.value for m in optargfn.__members__.values())
1✔
183
            parser.add_option(
1✔
184
                action="callback",
185
                callback=self._optparse_callback,
186
                type="choice",
187
                choices=tuple(values),
188
                metavar=self.argname,
189
                help=_help,
190
                default=OptionParser.DEFAULT_VALUE,
191
                *option_strings
192
            )
193
        else:
194
            parser.add_option(
×
195
                action="callback",
196
                callback=self._optparse_callback,
197
                type="string",
198
                metavar=self.argname,
199
                help=_help,
200
                default=OptionParser.DEFAULT_VALUE,
201
                *option_strings
202
            )
203

204
    def _optparse_bool_callback(self, option, opt_str, value, parser, bool_v):
1✔
205
        setattr(parser.values, self._param_name, bool_v)
×
206
        if self.custom_callback is not None:
×
207
            self.custom_callback(option, self._param_name, bool_v, parser)
×
208

209
    def _optparse_callback(self, option, opt, value, parser):
1✔
210
        v = self.type(value)
1✔
211
        setattr(parser.values, self._param_name, v)
1✔
212
        if self.custom_callback is not None:
1!
213
            self.custom_callback(option, self.name, v, parser)
×
214

215
    def iter_switches(self):
1✔
216
        """Iterate through the list of switches provided by the option
217

218
        :return: an iterator of (name, short_name, argname, help)
219
        """
220
        yield self.name, self.short_name(), self.argname, self.help
×
221

222

223
class ListOption(Option):
1✔
224
    """Option used to provide a list of values.
225

226
    On the command line, arguments are specified by a repeated use of the
227
    option. '-' is a special argument that resets the list. For example,
228
      --foo=a --foo=b
229
    sets the value of the 'foo' option to ['a', 'b'], and
230
      --foo=a --foo=b --foo=- --foo=c
231
    sets the value of the 'foo' option to ['c'].
232
    """
233

234
    def add_option(self, parser, short_name):
1✔
235
        """Add this option to an Optparse parser."""
236
        option_strings = ["--" + self.name]
×
237
        if short_name is not None:
×
238
            option_strings.append("-" + short_name)
×
239
        parser.add_option(
×
240
            action="callback",
241
            callback=self._optparse_callback,
242
            type="string",
243
            metavar=self.argname,
244
            help=self.help,
245
            default=[],
246
            *option_strings
247
        )
248

249
    def _optparse_callback(self, option, opt, value, parser):
1✔
250
        values = getattr(parser.values, self._param_name)
×
251
        if value == "-":
×
252
            del values[:]
×
253
        else:
254
            values.append(self.type(value))
×
255
        if self.custom_callback is not None:
×
256
            self.custom_callback(option, self._param_name, values, parser)
×
257

258

259
class OptionParser(optparse.OptionParser):
1✔
260
    """OptionParser that raises exceptions instead of exiting"""
261

262
    DEFAULT_VALUE = object()
1✔
263

264
    def error(self, message):
1✔
265
        raise CommandError(message)
1✔
266

267

268
def get_optparser(options):
1✔
269
    """Generate an optparse parser for bzrlib-style options"""
270

271
    parser = OptionParser()
1✔
272
    parser.remove_option("--help")
1✔
273
    for option in options.values():
1✔
274
        option.add_option(parser, option.short_name)
1✔
275
    return parser
1✔
276

277

278
def _standard_option(name, **kwargs):
1✔
279
    """Register a standard option."""
280
    # All standard options are implicitly 'global' ones
281
    Option.STD_OPTIONS[name] = Option(name, **kwargs)
1✔
282
    Option.OPTIONS[name] = Option.STD_OPTIONS[name]
1✔
283

284

285
def _global_option(name, **kwargs):
1✔
286
    """Register a global option."""
287
    Option.OPTIONS[name] = Option(name, **kwargs)
×
288

289

290
# Declare standard options
291
_standard_option("help", short_name="h", help="Show help message")
1✔
292
_standard_option("usage", help="Show usage message and options")
1✔
293

294

295
def _squish_command_name(name):
1✔
296
    """Gets a valid identifier from a command name.
297

298
    :param name: Command name.
299
    :rtype: str
300
    """
301
    return "cmd_" + name.replace("-", "_")
×
302

303

304
def _unsquish_command_name(identifier):
1✔
305
    """Gets a command name given its identifier.
306

307
    :param identifier: Command identifier.
308
    :rtype: str
309
    """
310
    return identifier[4:].replace("_", "-")
1✔
311

312

313
class Command:
1✔
314
    """Base class for commands.
315

316
    You should define subclasses of this to create a set of commands which
317
    then can be grouped using a :class:`cli`.
318

319
    The docstring for an actual command should give a single-line summary,
320
    then a complete description of the command. For commands taking options
321
    and arguments, a small grammar and a description of the options will be
322
    automatically inserted in the help text as needed.
323

324
    :cvar takes_options: List of options that may be given for this command,
325
        either strings referring to globally-defined options, or instances
326
        of :class:`Option`.
327
    :cvar takes_args: List of arguments, marking them as *optional*
328
        (``arg?``) or repeated (``arg+``, ``arg*``). Those will be passed as
329
        keyword arguments to the command's :meth:`run` method.
330
    :cvar aliases: Other names which may be used to refer to this command.
331
    :cvar exceptions: Either a single class or a list of classes of
332
        exceptions which will cause the command to exit with non-zero
333
        status, printing just the exception message to standard error
334
        instead of a full traceback.
335
    """
336

337
    takes_options = ()
1✔
338
    takes_args = ()
1✔
339
    aliases = ()
1✔
340
    hidden = False
1✔
341
    exceptions = None
1✔
342
    __cmd_param__ = {}
1✔
343

344
    def __init__(self, **param):
1✔
345
        assert self.__doc__ != Command.__doc__, "No help message set for {0!r}".format(
1✔
346
            self
347
        )
348
        self.supported_std_options = []
1✔
349
        self.param = param
1✔
350

351
    def _usage(self):
1✔
352
        """Return a single-line grammar for this action.
353

354
        Only describes arguments, not options.
355
        """
356
        result = self.name() + " "
×
357
        for aname in self.takes_args:
×
358
            aname = aname.upper()
×
359
            if aname[-1] in ("$", "+"):
×
360
                aname = aname[:-1] + "..."
×
361
            elif aname[-1] == "?":
×
362
                aname = "[" + aname[:-1] + "]"
×
363
            elif aname[-1] == "*":
×
364
                aname = "[" + aname[:-1] + "...]"
×
365
            result += aname + " "
×
366
        return result[:-1]  # Remove last space
×
367

368
    def prepare(self):
1✔
369
        """Prepare for execution.
370

371
        This is a hook method intended to be redefined in subclasses.
372
        By default it does nothing.
373
        """
374

375
    def cleanup(self):
1✔
376
        """Clean up after execution.
377

378
        This is a hook method intended to be redefined in subclasses.
379
        By default it does nothing.
380
        """
381

382
    def run(self):
1✔
383
        """Actually run the command.
384

385
        This is invoked with the options and arguments bound to keyword
386
        parameters.
387

388
        Return 0 or None if the command was successful, or a non-zero shell
389
        error code if not. It is okay for this method to raise exceptions.
390
        """
391
        raise NotImplementedError(
×
392
            "No implementation of command {0!r}".format(self.name())
393
        )
394

395
    def get_config_file(self):
1✔
396
        """Guess the name of the configuration file.
397

398
        This will work if at least ``cmd:config_file`` is defined in the
399
        command parameters. If ``cmd:config_env_var`` is there it is assumed
400
        to be the name of an environment variable which could point to an
401
        alternative path for the configuration file.
402

403
        :return: Path to configuration file, or ``None``.
404
        """
405
        env_var = self.param.get("cmd:config_env_var")
×
406
        cfgfile = self.param.get("cmd:config_file")
×
407

408
        if env_var and (env_var in os.environ):
×
409
            cfgfile = os.environ[env_var]
×
410

411
        return cfgfile
×
412

413
    def name(self):
1✔
414
        """Return the name of the action."""
415
        if isclass(self):
1!
416
            name = self.__name__
1✔
417
        else:
418
            name = self.__class__.__name__
×
419
        return _unsquish_command_name(name)
1✔
420

421
    name = classmethod(name)
1✔
422

423
    def help(self):  # noqa: A003
1✔
424
        """Return help message for this action."""
425
        from inspect import getdoc
×
426

427
        if self.__doc__ is Command.__doc__:
×
428
            return None
×
429
        return getdoc(self)
×
430

431
    def options(self):
1✔
432
        """Return dict of valid options for this action.
433

434
        Maps from long option name to option object.
435
        """
436
        result = Option.STD_OPTIONS.copy()
1✔
437
        std_names = result.keys()
1✔
438
        for opt in self.takes_options:
1✔
439
            if isinstance(opt, str):
1!
440
                opt = Option.OPTIONS[opt]
×
441
            result[opt.name] = opt
1✔
442
            if opt.name in std_names:
1!
443
                self.supported_std_options.append(opt.name)
1✔
444
        return result
1✔
445

446
    def run_argv_aliases(self, argv, alias_argv=None):
1✔
447
        """Parse the command line and run with extra aliases in alias_argv."""
448
        assert argv is not None
1✔
449

450
        args, opts = parse_args(self, argv, alias_argv)
1✔
451

452
        # Process the standard options
453
        if "help" in opts:  # e.g. command add --help
1!
454
            sys.stdout.write(self.get_help_text())
×
455
            return 0
×
456
        if "usage" in opts:  # e.g. command add --usage
1!
457
            sys.stdout.write(self.get_help_text(verbose=False))
×
458
            return 0
×
459

460
        # mix arguments and options into one dictionary
461
        cmdargs = _match_argform(self.name(), self.takes_args, args)
1✔
462
        cmdopts = {}
1✔
463
        for k, v in opts.items():
1✔
464
            if iskeyword(k):
1!
465
                k = "_" + k
×
466
            cmdopts[k.replace("-", "_")] = v
1✔
467

468
        all_cmd_args = cmdargs.copy()
1✔
469
        all_cmd_args.update(cmdopts)
1✔
470

471
        return self.run_direct(**all_cmd_args)
1✔
472

473
    def run_direct(self, *args, **kwargs):
1✔
474
        """Call run directly with objects (without parsing an argv list)."""
475
        try:
1✔
476
            try:
1✔
477
                # If prepare() returns something, it is assumed to be a non-zero
478
                # exit code or an error string -- so we do not execute run().
479
                #
480
                ret = self.prepare()
1✔
481
                if not ret:
1!
482
                    ret = self.run(*args, **kwargs)
1✔
483
            except Exception:  # noqa: PIE786
×
484
                # In this case, don't even bother calling sys.exc_info()
485
                if self.exceptions is None:
×
486
                    raise
×
487

488
                value = sys.exc_info()[1]
×
489
                if isinstance(value, self.exceptions):
×
490
                    return value
×
491
                else:
492
                    raise
×
493
        finally:
494
            # Ensure that cleanup() is executed.
495
            self.cleanup()
1!
496

497
        return ret
1✔
498

499
    def get_help_text(self, plain=True, verbose=True):
1✔
500
        """Return a text string with help for this command.
501

502
        :param additional_see_also: Additional help topics to be
503
            cross-referenced.
504
        :param plain: if False, raw help (reStructuredText) is
505
            returned instead of plain text.
506
        :param see_also_as_links: if True, convert items in 'See also'
507
            list to internal links (used by bzr_man rstx generator)
508
        :param verbose: if True, display the full help, otherwise
509
            leave out the descriptive sections and just display
510
            usage help (e.g. Purpose, Usage, Options) with a
511
            message explaining how to obtain full help.
512
        """
513
        doc = self.help()
×
514
        if doc is None:
×
515
            raise NotImplementedError(
×
516
                "sorry, no detailed help yet for {0!r}".format(self.name())
517
            )
518

519
        # Extract the summary (purpose) and sections out from the text
520
        purpose, sections, order = self._get_help_parts(doc)
×
521

522
        # If a custom usage section was provided, use it
523
        if "Usage" in sections:
×
524
            usage = sections.pop("Usage")
×
525
        else:
526
            usage = self._usage()
×
527

528
        # The header is the purpose and usage
529
        result = ""
×
530
        result += ":Purpose: {0!s}\n".format(purpose)
×
531
        if usage.find("\n") >= 0:
×
532
            result += ":Usage:\n{0!s}\n".format(usage)
×
533
        else:
534
            result += ":Usage:   {0!s}\n".format(usage)
×
535
        result += "\n"
×
536

537
        # Add the options
538
        #
539
        # XXX: optparse implicitly rewraps the help, and not always perfectly,
540
        # so we get <https://bugs.launchpad.net/bzr/+bug/249908>.  -- mbp
541
        # 20090319
542
        options = get_optparser(self.options()).format_option_help()
×
543
        if options.startswith("Options:"):
×
544
            result += ":" + options
×
545
        elif options.startswith("options:"):
×
546
            # Python 2.4 version of optparse
547
            result += ":Options:" + options[len("options:") :]
×
548
        else:
549
            result += options
×
550
        result += "\n"
×
551

552
        if verbose:
×
553
            # Add the description, indenting it 2 spaces
554
            # to match the indentation of the options
555
            if None in sections:
×
556
                text = sections.pop(None)
×
557
                text = "\n  ".join(text.splitlines())
×
558
                result += ":Description:\n  {0!s}\n\n".format(text)
×
559

560
            # Add the custom sections (e.g. Examples). Note that there's no need
561
            # to indent these as they must be indented already in the source.
562
            if sections:
×
563
                for label in order:
×
564
                    if label in sections:
×
565
                        result += ":{0!s}:\n{1!s}\n".format(label, sections[label])
×
566
                result += "\n"
×
567
        else:
568
            result += 'See "help {0!s}" for more details and examples.\n\n'.format(
×
569
                self.name()
570
            )
571

572
        # Add the aliases, source (plug-in) and see also links, if any
573
        if self.aliases:
×
574
            result += ":Aliases: "
×
575
            result += ", ".join(self.aliases) + "\n"
×
576

577
        # If this will be rendered as plain text, convert it
578
        if plain:
×
579
            result = rst_to_plain_text(result)
×
580
        return result
×
581

582
    def _get_help_parts(text):
1✔
583
        """Split help text into a summary and named sections.
584

585
        :return: (summary,sections,order) where summary is the top line and
586
            sections is a dictionary of the rest indexed by section name.
587
            order is the order the section appear in the text.
588
            A section starts with a heading line of the form ":xxx:".
589
            Indented text on following lines is the section value.
590
            All text found outside a named section is assigned to the
591
            default section which is given the key of None.
592
        """
593

594
        def save_section(sections, order, label, section):
×
595
            if len(section) > 0:
×
596
                if label in sections:
×
597
                    sections[label] += "\n" + section
×
598
                else:
599
                    order.append(label)
×
600
                    sections[label] = section
×
601

602
        lines = text.rstrip().splitlines()
×
603
        summary = lines.pop(0) if lines else ""
×
604
        sections = {}
×
605
        order = []
×
606
        label, section = None, ""
×
607
        for line in lines:
×
608
            if line.startswith(":") and line.endswith(":") and len(line) > 2:
×
609
                save_section(sections, order, label, section)
×
610
                label, section = line[1:-1], ""
×
611
            elif (label is not None) and len(line) > 1 and not line[0].isspace():
×
612
                save_section(sections, order, label, section)
×
613
                label, section = None, line
×
614
            else:
615
                if len(section) > 0:
×
616
                    section += "\n" + line
×
617
                else:
618
                    section = line
×
619
        save_section(sections, order, label, section)
×
620
        return summary, sections, order
×
621

622
    _get_help_parts = staticmethod(_get_help_parts)
1✔
623

624

625
def parse_args(cmd, argv, alias_argv=None):
1✔
626
    """Parse command line.
627

628
    Arguments and options are parsed at this level before being passed
629
    down to specific command handlers.  This routine knows, from a
630
    lookup table, something about the available options, what optargs
631
    they take, and which commands will accept them.
632
    """
633
    # TODO: make it a method of the command?
634
    parser = get_optparser(cmd.options())
1✔
635
    if alias_argv is not None:
1!
636
        args = alias_argv + argv
×
637
    else:
638
        args = argv
1✔
639

640
    options, args = parser.parse_args(args)
1✔
641
    opts = {
1✔
642
        k: v for k, v in options.__dict__.items() if v is not OptionParser.DEFAULT_VALUE
643
    }
644
    return args, opts
1✔
645

646

647
def _match_argform(cmd, takes_args, args):
1✔
648
    argdict = {}
1✔
649

650
    # step through args and takes_args, allowing appropriate 0-many matches
651
    for ap in takes_args:
1!
652
        argname = ap[:-1]
×
653
        if ap[-1] == "?":
×
654
            if args:
×
655
                argdict[argname] = args.pop(0)
×
656
        elif ap[-1] == "*":  # all remaining arguments
×
657
            if args:
×
658
                argdict[argname + "_list"] = args[:]
×
659
                args = []
×
660
            else:
661
                argdict[argname + "_list"] = ()
×
662
        elif ap[-1] == "+":
×
663
            if not args:
×
664
                raise CommandError(
×
665
                    "command {0!r} needs one or more {1!s}".format(cmd, argname.upper())
666
                )
667
            else:
668
                argdict[argname + "_list"] = args[:]
×
669
                args = []
×
670
        elif ap[-1] == "$":  # all but one
×
671
            if len(args) < 2:
×
672
                raise CommandError(
×
673
                    "command {0!r} needs one or more {1!s}".format(cmd, argname.upper())
674
                )
675
            argdict[argname + "_list"] = args[:-1]
×
676
            args[:-1] = []
×
677
        else:
678
            # just a plain arg
679
            argname = ap
×
680
            if not args:
×
681
                raise CommandError(
×
682
                    "command {0!r} requires argument {1!s}".format(cmd, argname.upper())
683
                )
684
            else:
685
                argdict[argname] = args.pop(0)
×
686

687
    if args:
1!
688
        raise CommandError(
×
689
            "extra argument to command {0!r}: {1!s}".format(cmd, args[0])
690
        )
691

692
    return argdict
1✔
693

694

695
def ignore_pipe_err(func):
1✔
696
    """Decorator that suppresses pipe/interrupt errors.
697

698
    :param func: Function to decorate.
699
    """
700

701
    def ignore_pipe(*args, **kwargs):
1✔
702
        import errno
×
703

704
        try:
×
705
            result = func(*args, **kwargs)
×
706
            sys.stdout.flush()
×
707
            return result
×
708
        except IOError as e:
×
709
            if getattr(e, "errno", None) is None:
×
710
                raise
×
711
            # Win32 raises IOError with errno=0 on a broken pipe
712
            if sys.platform == "win32" and (e.errno not in (0, errno.EINVAL)):
×
713
                raise
×
714
            if e.errno != errno.EPIPE:
×
715
                raise
×
716
        except KeyboardInterrupt:
×
717
            pass
×
718

719
    return ignore_pipe
1✔
720

721

722
class cmd_help(Command):
1✔
723
    """Show help on a command."""
724

725
    takes_args = ["topic?"]
1✔
726
    aliases = ["?", "--help", "-?", "-h"]
1✔
727

728
    def run(self, topic=None, long=False):
1✔
729
        if topic is None:
×
730
            if long:
×
731
                topic = "commands"
×
732
            else:
733
                topic = "help"
×
734
        if "cmd:help_output" in self.param:
×
735
            self.param["cmd:help_output"](topic)
×
736

737
    run = ignore_pipe_err(run)
1✔
738

739

740
_cli_top_level_help_text = """\
1✔
741
Usage: {0!s} <command> [flags...] [arguments...]
742

743
Available commands:
744
{1!s}
745
Getting more help:
746
   help commands   Get a list of available commands.
747
   help <command>  Show help on the given <command>.
748
"""
749

750

751
class CLI:
1✔
752
    """Groups commands and provides a command line interface to them.
753

754
    :ivar _registry: Dictionary containing all registered commands, mapping
755
        names to :class:`Command` instances.
756
    """
757

758
    def scan_commands(module, klass=None, **param):
1✔
759
        """Scans a module for commands of a given class.
760

761
        :param module: Module to scan commands in. If a string is given, it
762
            is assumed to be a module name and it will be imported.
763
        :param klass: Specify a base class of which subclasses will be
764
            searched for.
765
        :param param: Keyword arguments passed to commands when
766
            instantiating them.
767
        :return: Generator which yields *(name, command)* pairs.
768
        """
769
        if isinstance(module, str):
1!
770
            module = __import__(module, {}, {}, "dummy")
×
771
        if isinstance(module, types.ModuleType):
1!
772
            module = module.__dict__
×
773

774
        if klass is None:
1!
775
            klass = Command
1✔
776

777
        assert isinstance(module, dict)
1✔
778

779
        for item in module.values():
1✔
780
            if item is klass or not isclass(item):
1!
781
                continue
×
782
            if issubclass(item, klass) and item.__name__.startswith("cmd_"):
1!
783
                item.__cmd_param__ = param
1✔
784
                yield (item.name(), item)
1✔
785

786
    scan_commands = staticmethod(scan_commands)
1✔
787

788
    def from_tool_name(toolname, **param):
1✔
789
        """Creates a CLI for a given tool name.
790

791
        This generates the names of the configuration file and environment
792
        variable used to override it from the name of the tool.
793

794
        :param toolname: Name of the tool.
795
        :rtype: :class:`CLI`
796
        """
797
        if "cli_commands_module" in param:
×
798
            modname = param["cli_commands_module"]
×
799
            del param["cli_commands_module"]
×
800
        else:
801
            modname = toolname.replace("-", "")
×
802

803
        if "cli_config_file_envvar" in param:
×
804
            envvar = param["cli_config_file_envvar"]
×
805
            del param["cli_config_file_envvar"]
×
806
        else:
807
            envvar = toolname.replace("-", "_").upper() + "_CONF"
×
808

809
        if "cli_config_file_path" in param:
×
810
            conffile = param["cli_config_file_path"]
×
811
            del param["cli_config_file_path"]
×
812
        else:
813
            conffile = "/etc/{0!s}.conf".format(toolname)
×
814

815
        return CLI(
×
816
            name=toolname,
817
            config_file=conffile,
818
            config_env_var=envvar,
819
            commands=modname,
820
            **param
821
        )
822

823
    from_tool_name = staticmethod(from_tool_name)
1✔
824

825
    def __init__(
1✔
826
        self,
827
        name=None,
828
        config_file=None,
829
        config_env_var=None,
830
        commands=None,
831
        *arg,
832
        **param
833
    ):
834
        """Create a new command line interface controller.
835

836
        :param name: Tool name. If not given, the base name of
837
            ``sys.argv[0]`` will be used, which usually will be the desired
838
            behavior.
839
        :param config_file: Path to the configuration file.
840
        :param config_env_var: Environment variable name which can be used
841
            to override the location of the configuration file.
842
        :param commands: List of modules (or strings with module names) from
843
            which commands will be used. As an option, you may directly pass
844
            a dictionary mapping names to instances of :class:`Command`. If
845
            not given, the module from which the class is being instantiated
846
            will be used.
847
        """
848
        self.name = name or sys.argv[0]
1✔
849
        self._registry = {}
1✔
850

851
        param["cmd:help_output"] = self._output_help
1✔
852
        param["cmd:tool_name"] = self.name
1✔
853

854
        if isinstance(config_env_var, str) and config_env_var:
1!
855
            param["cmd:config_env_var"] = config_env_var
×
856
        if isinstance(config_file, str) and config_file:
1!
857
            param["cmd:config_file"] = config_file
×
858

859
        self.add_commands(commands, **param)
1✔
860

861
        # Add our built-in commands ("help" for now)
862
        self.add_command(cmd_help, **param)
1✔
863

864
    def add_command(self, command, **param):
1✔
865
        """Adds a single command to the command registry."""
866
        assert issubclass(command, Command)
1✔
867
        self.add_commands((command,), **param)
1✔
868

869
    def add_commands(self, commands=None, *arg, **kw):
1✔
870
        """Register commands.
871

872
        :param commands: List of modules (or strings with module names) from
873
            which commands will be used. As an option, you may directly pass
874
            a dictionary mapping names to instances of :class:`Command`. If
875
            not give, the module from which the class is being instantiated
876
            will be used.
877
        """
878
        kw["cmd:help_output"] = self._output_help
1✔
879

880
        if commands is None:
1!
881
            commands = [sys._getframe(2).f_globals]
×
882
        elif isinstance(commands, (list, tuple)):
1!
883
            commands = {c.name(): c for c in commands}
1✔
884
        if isinstance(commands, (dict, str, types.ModuleType)):
1!
885
            commands = [commands]
1✔
886

887
        for item in commands:
1✔
888
            self._registry.update(self.scan_commands(item, *arg, **kw))
1✔
889

890
    def _output_help_commands(self, hidden=False, indent=0):
1✔
891
        """Generate text output for 'help commands'
892

893
        :param hidden: Whether to include hidden commands in the output.
894
        :param indent: Indent each line with the given amount of spaces.
895
        """
896
        cmdnames = [n for n, c in self._registry.items() if c.hidden == hidden]
×
897

898
        if not cmdnames:
×
899
            return ""
×
900

901
        max_name = max((len(n) for n in cmdnames))
×
902
        result = []
×
903

904
        # It is better to have the output sorted by name.
905
        cmdnames.sort()
×
906

907
        if indent:
×
908
            indent = " " * indent
×
909
        else:
910
            indent = ""
×
911

912
        for name in cmdnames:
×
913
            cmd = self.get_command(name)
×
914
            helptext = cmd.help()
×
915
            if helptext:
×
916
                firstline = helptext.split("\n", 1)[0]
×
917
            else:
918
                firstline = ""
×
919

920
            result.append(
×
921
                "{0!s}{1!s:{2}}  {3!s}\n".format(indent, name, max_name, firstline)
922
            )
923

924
        return "".join(result)
×
925

926
    def _output_help(self, topic):
1✔
927
        """Print out help for a given topic.
928

929
        :param topic: A command name, or one of ``commands`` or
930
            ``hidden-commands`` strings.
931
        """
932
        if topic == "help":
×
933
            text = _cli_top_level_help_text.format(
×
934
                self.name, self._output_help_commands(indent=3)
935
            )
936
        elif topic == "commands":
×
937
            text = self._output_help_commands()
×
938
        elif topic == "hidden-commands":
×
939
            text = self._output_help_commands(hidden=True)
×
940
        elif self.has_command(topic):
×
941
            text = self.get_command(topic).get_help_text()
×
942
        else:
943
            text = "{0!s}: Unavailable help topic '{1!s}'\n".format(self.name, topic)
×
944

945
        sys.stdout.write(text)
×
946

947
    def get_command(self, name, alias=True):
1✔
948
        """Obtain a command given its name.
949

950
        :param name: Name of the command.
951
        :param alias: Allow searching for aliases.
952
        :rtype: :class:`Command`
953
        """
954
        assert self._registry
1✔
955

956
        command = self._registry.get(name)
1✔
957
        if command:
1✔
958
            return command(**command.__cmd_param__)
1✔
959
        elif alias:
1✔
960
            for command in self._registry.values():
1✔
961
                if name in command.aliases:
1✔
962
                    return command(**command.__cmd_param__)
1✔
963

964
        raise KeyError("No such command {0!r}".format(name))
1✔
965

966
    def has_command(self, name, alias=True):
1✔
967
        """Check whether a command exists given its name.
968

969
        :param name: Name of the command.
970
        :param alias: Allow searching for aliases.
971
        :rtype: bool
972
        """
973
        assert self._registry
×
974

975
        if alias:
×
976
            for command in self._registry.values():
×
977
                if name in command.aliases:
×
978
                    return True
×
979

980
        return name in self._registry
×
981

982
    def run(self, argv=None):
1✔
983
        """Run the command line tool.
984

985
        :param argv: Command line arguments, being the first one interpreted
986
            as command name. If not given, then ``sys.argv[1:]`` will be
987
            used.
988
        """
989
        if argv is None:
1!
990
            argv = [
×
991
                item.decode("utf-8") if isinstance(item, bytes) else item
992
                for item in sys.argv[1:]
993
            ]
994

995
        try:
1✔
996
            cmd = argv and argv.pop(0) or "help"
1✔
997
            cmd = self.get_command(cmd)
1✔
998
        except KeyError:
×
999
            return "{0!s}: command '{1!s}' does not exist.".format(self.name, cmd)
×
1000

1001
        try:
1✔
1002
            return cmd.run_argv_aliases(argv) or 0
1✔
1003
        except CommandError as e:
1✔
1004
            return "{0!s}: {1!s}".format(self.name, e)
1✔
1005

1006

1007
def main(toolname=None, argv=None, **kw):
1✔
1008
    """Convenience function to run a command line tool.
1009

1010
    This will create a :class:`CLI`, scan for commands in a module name
1011
    determined given the tool name, and then run it.
1012

1013
    :param toolname: Command line tool name. If not given, then
1014
        ``sys.argv[0]`` will be used.
1015
    :param argv: Command line arguments. If not given, then
1016
        ``sys.argv[1:]`` will be used.
1017
    """
1018
    if toolname is None:
×
1019
        if argv is None:
×
1020
            from pathlib import Path
×
1021

1022
            toolname = Path(sys.argv[0]).name
×
1023
            argv = sys.argv[1:]
×
1024
        else:
1025
            toolname = argv.pop()
×
1026

1027
    raise SystemExit(CLI.from_tool_name(toolname, **kw).run(argv) or 0)
×
1028

1029

1030
__all__ = ["CLI", "Command", "Option"]
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc