1# -*- coding: utf-8 -*- 2from __future__ import absolute_import, division, print_function 3 4import functools 5import os 6import sys 7from collections import defaultdict 8from textwrap import TextWrapper 9 10from plumbum import colors, local 11from plumbum.cli.i18n import get_translation_for 12from plumbum.lib import getdoc, six 13 14from .switches import ( 15 CountOf, 16 Flag, 17 MissingArgument, 18 MissingMandatorySwitch, 19 PositionalArgumentsError, 20 SubcommandError, 21 SwitchCombinationError, 22 SwitchError, 23 UnknownSwitch, 24 WrongArgumentType, 25 switch, 26) 27from .terminal import get_terminal_size 28 29_translation = get_translation_for(__name__) 30T_, ngettext = _translation.gettext, _translation.ngettext 31 32 33class ShowHelp(SwitchError): 34 pass 35 36 37class ShowHelpAll(SwitchError): 38 pass 39 40 41class ShowVersion(SwitchError): 42 pass 43 44 45class SwitchParseInfo(object): 46 __slots__ = ["swname", "val", "index", "__weakref__"] 47 48 def __init__(self, swname, val, index): 49 self.swname = swname 50 self.val = val 51 self.index = index 52 53 54class Subcommand(object): 55 def __init__(self, name, subapplication): 56 self.name = name 57 self.subapplication = subapplication 58 59 def get(self): 60 if isinstance(self.subapplication, str): 61 modname, clsname = self.subapplication.rsplit(".", 1) 62 mod = __import__(modname, None, None, "*") 63 try: 64 cls = getattr(mod, clsname) 65 except AttributeError: 66 raise ImportError("cannot import name {}".format(clsname)) 67 self.subapplication = cls 68 return self.subapplication 69 70 def __repr__(self): 71 return T_("Subcommand({self.name}, {self.subapplication})").format(self=self) 72 73 74_switch_groups = ["Switches", "Meta-switches"] 75_switch_groups_l10n = [T_("Switches"), T_("Meta-switches")] 76 77 78# =================================================================================================== 79# CLI Application base class 80# =================================================================================================== 81 82 83class Application(object): 84 """The base class for CLI applications; your "entry point" class should derive from it, 85 define the relevant switch functions and attributes, and the ``main()`` function. 86 The class defines two overridable "meta switches" for version (``-v``, ``--version``) 87 help (``-h``, ``--help``), and help-all (``--help-all``). 88 89 The signature of the main function matters: any positional arguments (e.g., non-switch 90 arguments) given on the command line are passed to the ``main()`` function; if you wish 91 to allow unlimited number of positional arguments, use varargs (``*args``). The names 92 of the arguments will be shown in the help message. 93 94 The classmethod ``run`` serves as the entry point of the class. It parses the command-line 95 arguments, invokes switch functions and enters ``main``. You should **not override** this 96 method. 97 98 Usage:: 99 100 class FileCopier(Application): 101 stat = Flag("p", "copy stat info as well") 102 103 def main(self, src, dst): 104 if self.stat: 105 shutil.copy2(src, dst) 106 else: 107 shutil.copy(src, dst) 108 109 if __name__ == "__main__": 110 FileCopier.run() 111 112 There are several class-level attributes you may set: 113 114 * ``PROGNAME`` - the name of the program; if ``None`` (the default), it is set to the 115 name of the executable (``argv[0]``); can be in color. If only a color, will be applied to the name. 116 117 * ``VERSION`` - the program's version (defaults to ``1.0``, can be in color) 118 119 * ``DESCRIPTION`` - a short description of your program (shown in help). If not set, 120 the class' ``__doc__`` will be used. Can be in color. 121 122 * ``DESCRIPTION_MORE`` - a detailed description of your program (shown in help). The text will be printed 123 by paragraphs (specified by empty lines between them). The indentation of each paragraph will be the 124 indentation of its first line. List items are identified by their first non-whitespace character being 125 one of '-', '*', and '/'; so that they are not combined with preceding paragraphs. Bullet '/' is 126 "invisible", meaning that the bullet itself will not be printed to the output. 127 128 * ``USAGE`` - the usage line (shown in help). 129 130 * ``COLOR_USAGE_TITLE`` - The color of the usage line's header. 131 132 * ``COLOR_USAGE`` - The color of the usage line. 133 134 * ``COLOR_GROUPS`` - A dictionary that sets colors for the groups, like Meta-switches, Switches, 135 and Subcommands. 136 137 * ``COLOR_GROUP_TITLES`` - A dictionary that sets colors for the group titles. If the dictionary is empty, 138 it defaults to ``COLOR_GROUPS``. 139 140 * ``SUBCOMMAND_HELPMSG`` - Controls the printing of extra "see subcommand -h" help message. 141 Default is a message, set to False to remove. 142 143 * ``ALLOW_ABBREV`` - Controls whether partial switch names are supported, for example '--ver' will match 144 '--verbose'. Default is False for backward consistency with previous plumbum releases. Note that ambiguous 145 abbreviations will not match, for example if --foothis and --foothat are defined, then --foo will not match. 146 147 A note on sub-commands: when an application is the root, its ``parent`` attribute is set to 148 ``None``. When it is used as a nested-command, ``parent`` will point to its direct ancestor. 149 Likewise, when an application is invoked with a sub-command, its ``nested_command`` attribute 150 will hold the chosen sub-application and its command-line arguments (a tuple); otherwise, it 151 will be set to ``None`` 152 153 """ 154 155 PROGNAME = None 156 DESCRIPTION = None 157 DESCRIPTION_MORE = None 158 VERSION = None 159 USAGE = None 160 COLOR_USAGE = None 161 COLOR_USAGE_TITLE = None 162 COLOR_GROUPS = None 163 COLOR_GROUP_TITLES = None 164 CALL_MAIN_IF_NESTED_COMMAND = True 165 SUBCOMMAND_HELPMSG = T_("see '{parent} {sub} --help' for more info") 166 ALLOW_ABBREV = False 167 168 parent = None 169 nested_command = None 170 _unbound_switches = () 171 172 def __new__(cls, executable=None): 173 """Allows running the class directly as a shortcut for main. 174 This is necessary for some setup scripts that want a single function, 175 instead of an expression with a dot in it.""" 176 177 if executable is None: 178 return cls.run() 179 # This return value was not a class instance, so __init__ is never called 180 else: 181 return super(Application, cls).__new__(cls) 182 183 def __init__(self, executable): 184 # Filter colors 185 186 if self.PROGNAME is None: 187 self.PROGNAME = os.path.basename(executable) 188 elif isinstance(self.PROGNAME, colors._style): 189 self.PROGNAME = self.PROGNAME | os.path.basename(executable) 190 elif colors.filter(self.PROGNAME) == "": 191 self.PROGNAME = colors.extract(self.PROGNAME) | os.path.basename(executable) 192 if self.DESCRIPTION is None: 193 self.DESCRIPTION = getdoc(self) 194 195 # Allow None for the colors 196 self.COLOR_GROUPS = defaultdict( 197 lambda: colors.do_nothing, 198 dict() if type(self).COLOR_GROUPS is None else type(self).COLOR_GROUPS, 199 ) 200 201 self.COLOR_GROUP_TITLES = defaultdict( 202 lambda: colors.do_nothing, 203 self.COLOR_GROUPS 204 if type(self).COLOR_GROUP_TITLES is None 205 else type(self).COLOR_GROUP_TITLES, 206 ) 207 if type(self).COLOR_USAGE is None: 208 self.COLOR_USAGE = colors.do_nothing 209 210 self.executable = executable 211 self._switches_by_name = {} 212 self._switches_by_func = {} 213 self._switches_by_envar = {} 214 self._subcommands = {} 215 216 for cls in reversed(type(self).mro()): 217 for obj in cls.__dict__.values(): 218 if isinstance(obj, Subcommand): 219 name = colors.filter(obj.name) 220 if name.startswith("-"): 221 raise SubcommandError( 222 T_("Sub-command names cannot start with '-'") 223 ) 224 # it's okay for child classes to override sub-commands set by their parents 225 self._subcommands[name] = obj 226 continue 227 228 swinfo = getattr(obj, "_switch_info", None) 229 if not swinfo: 230 continue 231 for name in swinfo.names: 232 if name in self._unbound_switches: 233 continue 234 if ( 235 name in self._switches_by_name 236 and not self._switches_by_name[name].overridable 237 ): 238 raise SwitchError( 239 T_( 240 "Switch {name} already defined and is not overridable" 241 ).format(name=name) 242 ) 243 self._switches_by_name[name] = swinfo 244 self._switches_by_func[swinfo.func] = swinfo 245 if swinfo.envname: 246 self._switches_by_envar[swinfo.envname] = swinfo 247 248 @property 249 def root_app(self): 250 return self.parent.root_app if self.parent else self 251 252 @classmethod 253 def unbind_switches(cls, *switch_names): 254 """Unbinds the given switch names from this application. For example 255 256 :: 257 258 class MyApp(cli.Application): 259 pass 260 MyApp.unbind_switches("--version") 261 262 """ 263 cls._unbound_switches += tuple( 264 name.lstrip("-") for name in switch_names if name 265 ) 266 267 @classmethod 268 def subcommand(cls, name, subapp=None): 269 """Registers the given sub-application as a sub-command of this one. This method can be 270 used both as a decorator and as a normal ``classmethod``:: 271 272 @MyApp.subcommand("foo") 273 class FooApp(cli.Application): 274 pass 275 276 Or :: 277 278 MyApp.subcommand("foo", FooApp) 279 280 .. versionadded:: 1.1 281 282 .. versionadded:: 1.3 283 The sub-command can also be a string, in which case it is treated as a 284 fully-qualified class name and is imported on demand. For example, 285 286 MyApp.subcommand("foo", "fully.qualified.package.FooApp") 287 288 """ 289 290 def wrapper(subapp): 291 attrname = "_subcommand_{}".format( 292 subapp if isinstance(subapp, str) else subapp.__name__ 293 ) 294 setattr(cls, attrname, Subcommand(name, subapp)) 295 return subapp 296 297 return wrapper(subapp) if subapp else wrapper 298 299 def _get_partial_matches(self, partialname): 300 matches = [] 301 for switch in self._switches_by_name: 302 if switch.startswith(partialname): 303 matches += [ 304 switch, 305 ] 306 return matches 307 308 def _parse_args(self, argv): 309 tailargs = [] 310 swfuncs = {} 311 index = 0 312 313 while argv: 314 index += 1 315 a = argv.pop(0) 316 val = None 317 if a == "--": 318 # end of options, treat the rest as tailargs 319 tailargs.extend(argv) 320 break 321 322 if a in self._subcommands: 323 subcmd = self._subcommands[a].get() 324 self.nested_command = ( 325 subcmd, 326 [self.PROGNAME + " " + self._subcommands[a].name] + argv, 327 ) 328 break 329 330 elif a.startswith("--") and len(a) >= 3: 331 # [--name], [--name=XXX], [--name, XXX], [--name, ==, XXX], 332 # [--name=, XXX], [--name, =XXX] 333 eqsign = a.find("=") 334 if eqsign >= 0: 335 name = a[2:eqsign] 336 argv.insert(0, a[eqsign:]) 337 else: 338 name = a[2:] 339 340 if self.ALLOW_ABBREV: 341 partials = self._get_partial_matches(name) 342 if len(partials) == 1: 343 name = partials[0] 344 elif len(partials) > 1: 345 raise UnknownSwitch( 346 T_("Ambiguous partial switch {0}").format("--" + name) 347 ) 348 349 swname = "--" + name 350 if name not in self._switches_by_name: 351 raise UnknownSwitch(T_("Unknown switch {0}").format(swname)) 352 swinfo = self._switches_by_name[name] 353 if swinfo.argtype: 354 if not argv: 355 raise MissingArgument( 356 T_("Switch {0} requires an argument").format(swname) 357 ) 358 a = argv.pop(0) 359 if a and a[0] == "=": 360 if len(a) >= 2: 361 val = a[1:] 362 else: 363 if not argv: 364 raise MissingArgument( 365 T_("Switch {0} requires an argument").format(swname) 366 ) 367 val = argv.pop(0) 368 else: 369 val = a 370 371 elif a.startswith("-") and len(a) >= 2: 372 # [-a], [-a, XXX], [-aXXX], [-abc] 373 name = a[1] 374 swname = "-" + name 375 if name not in self._switches_by_name: 376 raise UnknownSwitch(T_("Unknown switch {0}").format(swname)) 377 swinfo = self._switches_by_name[name] 378 if swinfo.argtype: 379 if len(a) >= 3: 380 val = a[2:] 381 else: 382 if not argv: 383 raise MissingArgument( 384 T_("Switch {0} requires an argument").format(swname) 385 ) 386 val = argv.pop(0) 387 elif len(a) >= 3: 388 argv.insert(0, "-" + a[2:]) 389 390 else: 391 if a.startswith("-"): 392 raise UnknownSwitch(T_("Unknown switch {0}").format(a)) 393 tailargs.append(a) 394 continue 395 396 # handle argument 397 val = self._handle_argument(val, swinfo.argtype, name) 398 399 if swinfo.func in swfuncs: 400 if swinfo.list: 401 swfuncs[swinfo.func].val[0].append(val) 402 else: 403 if swfuncs[swinfo.func].swname == swname: 404 raise SwitchError(T_("Switch {0} already given").format(swname)) 405 else: 406 raise SwitchError( 407 T_("Switch {0} already given ({1} is equivalent)").format( 408 swfuncs[swinfo.func].swname, swname 409 ) 410 ) 411 else: 412 if swinfo.list: 413 swfuncs[swinfo.func] = SwitchParseInfo(swname, ([val],), index) 414 elif val is NotImplemented: 415 swfuncs[swinfo.func] = SwitchParseInfo(swname, (), index) 416 else: 417 swfuncs[swinfo.func] = SwitchParseInfo(swname, (val,), index) 418 419 # Extracting arguments from environment variables 420 envindex = 0 421 for env, swinfo in self._switches_by_envar.items(): 422 envindex -= 1 423 envval = local.env.get(env) 424 if envval is None: 425 continue 426 427 if swinfo.func in swfuncs: 428 continue # skip if overridden by command line arguments 429 430 val = self._handle_argument(envval, swinfo.argtype, env) 431 envname = "${}".format(env) 432 if swinfo.list: 433 # multiple values over environment variables are not supported, 434 # this will require some sort of escaping and separator convention 435 swfuncs[swinfo.func] = SwitchParseInfo(envname, ([val],), envindex) 436 elif val is NotImplemented: 437 swfuncs[swinfo.func] = SwitchParseInfo(envname, (), envindex) 438 else: 439 swfuncs[swinfo.func] = SwitchParseInfo(envname, (val,), envindex) 440 441 return swfuncs, tailargs 442 443 @classmethod 444 def autocomplete(cls, argv): 445 """This is supplied to make subclassing and testing argument completion methods easier""" 446 pass 447 448 @staticmethod 449 def _handle_argument(val, argtype, name): 450 if argtype: 451 try: 452 return argtype(val) 453 except (TypeError, ValueError): 454 ex = sys.exc_info()[1] # compat 455 raise WrongArgumentType( 456 T_( 457 "Argument of {name} expected to be {argtype}, not {val!r}:\n {ex!r}" 458 ).format(name=name, argtype=argtype, val=val, ex=ex) 459 ) 460 else: 461 return NotImplemented 462 463 def _validate_args(self, swfuncs, tailargs): 464 if six.get_method_function(self.help) in swfuncs: 465 raise ShowHelp() 466 if six.get_method_function(self.helpall) in swfuncs: 467 raise ShowHelpAll() 468 if six.get_method_function(self.version) in swfuncs: 469 raise ShowVersion() 470 471 requirements = {} 472 exclusions = {} 473 for swinfo in self._switches_by_func.values(): 474 if swinfo.mandatory and not swinfo.func in swfuncs: 475 raise MissingMandatorySwitch( 476 T_("Switch {0} is mandatory").format( 477 "/".join( 478 ("-" if len(n) == 1 else "--") + n for n in swinfo.names 479 ) 480 ) 481 ) 482 requirements[swinfo.func] = { 483 self._switches_by_name[req] for req in swinfo.requires 484 } 485 exclusions[swinfo.func] = { 486 self._switches_by_name[exc] for exc in swinfo.excludes 487 } 488 489 # TODO: compute topological order 490 491 gotten = set(swfuncs.keys()) 492 for func in gotten: 493 missing = {f.func for f in requirements[func]} - gotten 494 if missing: 495 raise SwitchCombinationError( 496 T_("Given {0}, the following are missing {1}").format( 497 swfuncs[func].swname, 498 [self._switches_by_func[f].names[0] for f in missing], 499 ) 500 ) 501 invalid = {f.func for f in exclusions[func]} & gotten 502 if invalid: 503 raise SwitchCombinationError( 504 T_("Given {0}, the following are invalid {1}").format( 505 swfuncs[func].swname, [swfuncs[f].swname for f in invalid] 506 ) 507 ) 508 509 m = six.getfullargspec(self.main) 510 max_args = six.MAXSIZE if m.varargs else len(m.args) - 1 511 min_args = len(m.args) - 1 - (len(m.defaults) if m.defaults else 0) 512 if len(tailargs) < min_args: 513 raise PositionalArgumentsError( 514 ngettext( 515 "Expected at least {0} positional argument, got {1}", 516 "Expected at least {0} positional arguments, got {1}", 517 min_args, 518 ).format(min_args, tailargs) 519 ) 520 elif len(tailargs) > max_args: 521 raise PositionalArgumentsError( 522 ngettext( 523 "Expected at most {0} positional argument, got {1}", 524 "Expected at most {0} positional arguments, got {1}", 525 max_args, 526 ).format(max_args, tailargs) 527 ) 528 529 # Positional arguement validataion 530 if hasattr(self.main, "positional"): 531 tailargs = self._positional_validate( 532 tailargs, 533 self.main.positional, 534 self.main.positional_varargs, 535 m.args[1:], 536 m.varargs, 537 ) 538 539 elif hasattr(m, "annotations"): 540 args_names = list(m.args[1:]) 541 positional = [None] * len(args_names) 542 varargs = None 543 544 # All args are positional, so convert kargs to positional 545 for item in m.annotations: 546 if item == m.varargs: 547 varargs = m.annotations[item] 548 elif item != "return": 549 positional[args_names.index(item)] = m.annotations[item] 550 551 tailargs = self._positional_validate( 552 tailargs, positional, varargs, m.args[1:], m.varargs 553 ) 554 555 ordered = [ 556 (f, a) 557 for _, f, a in sorted((sf.index, f, sf.val) for f, sf in swfuncs.items()) 558 ] 559 return ordered, tailargs 560 561 def _positional_validate(self, args, validator_list, varargs, argnames, varargname): 562 """Makes sure args follows the validation given input""" 563 out_args = list(args) 564 565 for i in range(min(len(args), len(validator_list))): 566 567 if validator_list[i] is not None: 568 out_args[i] = self._handle_argument( 569 args[i], validator_list[i], argnames[i] 570 ) 571 572 if len(args) > len(validator_list): 573 if varargs is not None: 574 out_args[len(validator_list) :] = [ 575 self._handle_argument(a, varargs, varargname) 576 for a in args[len(validator_list) :] 577 ] 578 else: 579 out_args[len(validator_list) :] = args[len(validator_list) :] 580 581 return out_args 582 583 @classmethod 584 def run(cls, argv=None, exit=True): # @ReservedAssignment 585 """ 586 Runs the application, taking the arguments from ``sys.argv`` by default if 587 nothing is passed. If ``exit`` is 588 ``True`` (the default), the function will exit with the appropriate return code; 589 otherwise it will return a tuple of ``(inst, retcode)``, where ``inst`` is the 590 application instance created internally by this function and ``retcode`` is the 591 exit code of the application. 592 593 .. note:: 594 Setting ``exit`` to ``False`` is intendend for testing/debugging purposes only -- do 595 not override it in other situations. 596 """ 597 if argv is None: 598 argv = sys.argv 599 cls.autocomplete(argv) 600 argv = list(argv) 601 inst = cls(argv.pop(0)) 602 retcode = 0 603 try: 604 swfuncs, tailargs = inst._parse_args(argv) 605 ordered, tailargs = inst._validate_args(swfuncs, tailargs) 606 except ShowHelp: 607 inst.help() 608 except ShowHelpAll: 609 inst.helpall() 610 except ShowVersion: 611 inst.version() 612 except SwitchError: 613 ex = sys.exc_info()[1] # compatibility with python 2.5 614 print(T_("Error: {0}").format(ex)) 615 print(T_("------")) 616 inst.help() 617 retcode = 2 618 else: 619 for f, a in ordered: 620 f(inst, *a) 621 622 cleanup = None 623 if not inst.nested_command or inst.CALL_MAIN_IF_NESTED_COMMAND: 624 retcode = inst.main(*tailargs) 625 cleanup = functools.partial(inst.cleanup, retcode) 626 if not retcode and inst.nested_command: 627 subapp, argv = inst.nested_command 628 subapp.parent = inst 629 inst, retcode = subapp.run(argv, exit=False) 630 631 if cleanup: 632 cleanup() 633 634 if retcode is None: 635 retcode = 0 636 637 if exit: 638 sys.exit(retcode) 639 else: 640 return inst, retcode 641 642 @classmethod 643 def invoke(cls, *args, **switches): 644 """Invoke this application programmatically (as a function), in the same way ``run()`` 645 would. There are two key differences: the return value of ``main()`` is not converted to 646 an integer (returned as-is), and exceptions are not swallowed either. 647 648 :param args: any positional arguments for ``main()`` 649 :param switches: command-line switches are passed as keyword arguments, 650 e.g., ``foo=5`` for ``--foo=5`` 651 """ 652 653 inst = cls("") 654 655 swfuncs = inst._parse_kwd_args(switches) 656 ordered, tailargs = inst._validate_args(swfuncs, args) 657 for f, a in ordered: 658 f(inst, *a) 659 660 cleanup = None 661 if not inst.nested_command or inst.CALL_MAIN_IF_NESTED_COMMAND: 662 retcode = inst.main(*tailargs) 663 cleanup = functools.partial(inst.cleanup, retcode) 664 if not retcode and inst.nested_command: 665 subapp, argv = inst.nested_command 666 subapp.parent = inst 667 inst, retcode = subapp.run(argv, exit=False) 668 669 if cleanup: 670 cleanup() 671 672 return inst, retcode 673 674 def _parse_kwd_args(self, switches): 675 """Parses keywords (positional arguments), used by invoke.""" 676 swfuncs = {} 677 for index, (swname, val) in enumerate(switches.items(), 1): 678 switch = getattr(type(self), swname) 679 swinfo = self._switches_by_func[switch._switch_info.func] 680 if isinstance(switch, CountOf): 681 p = (range(val),) 682 elif swinfo.list and not hasattr(val, "__iter__"): 683 raise SwitchError( 684 T_("Switch {0} must be a sequence (iterable)").format(swname) 685 ) 686 elif not swinfo.argtype: 687 # a flag 688 if val not in (True, False, None, Flag): 689 raise SwitchError(T_("Switch {0} is a boolean flag").format(swname)) 690 p = () 691 else: 692 p = (val,) 693 swfuncs[swinfo.func] = SwitchParseInfo(swname, p, index) 694 return swfuncs 695 696 def main(self, *args): 697 """Implement me (no need to call super)""" 698 if self._subcommands: 699 if args: 700 print(T_("Unknown sub-command '{0}'").format(args[0])) 701 print(T_("------")) 702 self.help() 703 return 1 704 if not self.nested_command: 705 print(T_("No sub-command given")) 706 print(T_("------")) 707 self.help() 708 return 1 709 else: 710 print(T_("main() not implemented")) 711 return 1 712 713 def cleanup(self, retcode): 714 """Called after ``main()`` and all sub-applications have executed, to perform any necessary cleanup. 715 716 :param retcode: the return code of ``main()`` 717 """ 718 719 @switch( 720 ["--help-all"], 721 overridable=True, 722 group="Meta-switches", 723 help=T_("""Prints help messages of all sub-commands and quits"""), 724 ) 725 def helpall(self): 726 """Prints help messages of all sub-commands and quits""" 727 self.help() 728 print("") 729 730 if self._subcommands: 731 for name, subcls in sorted(self._subcommands.items()): 732 subapp = (subcls.get())("{} {}".format(self.PROGNAME, name)) 733 subapp.parent = self 734 for si in subapp._switches_by_func.values(): 735 if si.group == "Meta-switches": 736 si.group = "Hidden-switches" 737 subapp.helpall() 738 739 @switch( 740 ["-h", "--help"], 741 overridable=True, 742 group="Meta-switches", 743 help=T_("""Prints this help message and quits"""), 744 ) 745 def help(self): # @ReservedAssignment 746 """Prints this help message and quits""" 747 if self._get_prog_version(): 748 self.version() 749 print("") 750 if self.DESCRIPTION: 751 print(self.DESCRIPTION.strip() + "\n") 752 753 def split_indentation(s): 754 """Identifies the initial indentation (all spaces) of the string and returns the indentation as well 755 as the remainder of the line. 756 """ 757 i = 0 758 while i < len(s) and s[i] == " ": 759 i += 1 760 return s[:i], s[i:] 761 762 def paragraphs(text): 763 """Yields each paragraph of text along with its initial and subsequent indentations to be used by 764 textwrap.TextWrapper. 765 766 Identifies list items from their first non-space character being one of bullets '-', '*', and '/'. 767 However, bullet '/' is invisible and is removed from the list item. 768 769 :param text: The text to separate into paragraphs 770 """ 771 772 paragraph = None 773 initial_indent = "" 774 subsequent_indent = "" 775 776 def current(): 777 """Yields the current result if present.""" 778 if paragraph: 779 yield paragraph, initial_indent, subsequent_indent 780 781 for part in text.lstrip("\n").split("\n"): 782 indent, line = split_indentation(part) 783 784 if len(line) == 0: 785 # Starting a new paragraph 786 for item in current(): 787 yield item 788 yield "", "", "" 789 790 paragraph = None 791 initial_indent = "" 792 subsequent_indent = "" 793 else: 794 # Adding to current paragraph 795 def is_list_item(line): 796 """Returns true if the first element of 'line' is a bullet character.""" 797 bullets = ["-", "*", "/"] 798 return line[0] in bullets 799 800 def has_invisible_bullet(line): 801 """Returns true if the first element of 'line' is the invisible bullet ('/').""" 802 return line[0] == "/" 803 804 if is_list_item(line): 805 # Done with current paragraph 806 for item in current(): 807 yield item 808 809 if has_invisible_bullet(line): 810 line = line[1:] 811 812 paragraph = line 813 initial_indent = indent 814 815 # Calculate extra indentation for subsequent lines of this list item 816 i = 1 817 while i < len(line) and line[i] == " ": 818 i += 1 819 subsequent_indent = indent + " " * i 820 else: 821 if not paragraph: 822 # Start a new paragraph 823 paragraph = line 824 initial_indent = indent 825 subsequent_indent = indent 826 else: 827 # Add to current paragraph 828 paragraph = paragraph + " " + line 829 830 for item in current(): 831 yield item 832 833 def wrapped_paragraphs(text, width): 834 """Yields each line of each paragraph of text after wrapping them on 'width' number of columns. 835 836 :param text: The text to yield wrapped lines of 837 :param width: The width of the wrapped output 838 """ 839 if not text: 840 return 841 842 width = max(width, 1) 843 844 for paragraph, initial_indent, subsequent_indent in paragraphs(text): 845 wrapper = TextWrapper( 846 width, 847 initial_indent=initial_indent, 848 subsequent_indent=subsequent_indent, 849 ) 850 w = wrapper.wrap(paragraph) 851 for line in w: 852 yield line 853 if len(w) == 0: 854 yield "" 855 856 cols, _ = get_terminal_size() 857 for line in wrapped_paragraphs(self.DESCRIPTION_MORE, cols): 858 print(line) 859 860 m = six.getfullargspec(self.main) 861 tailargs = m.args[1:] # skip self 862 if m.defaults: 863 for i, d in enumerate(reversed(m.defaults)): 864 tailargs[-i - 1] = "[{}={}]".format(tailargs[-i - 1], d) 865 if m.varargs: 866 tailargs.append( 867 "{}...".format( 868 m.varargs, 869 ) 870 ) 871 tailargs = " ".join(tailargs) 872 873 utc = self.COLOR_USAGE_TITLE if self.COLOR_USAGE_TITLE else self.COLOR_USAGE 874 print(utc | T_("Usage:")) 875 876 with self.COLOR_USAGE: 877 if not self.USAGE: 878 if self._subcommands: 879 self.USAGE = T_( 880 " {progname} [SWITCHES] [SUBCOMMAND [SWITCHES]] {tailargs}\n" 881 ) 882 else: 883 self.USAGE = T_(" {progname} [SWITCHES] {tailargs}\n") 884 print( 885 self.USAGE.format( 886 progname=colors.filter(self.PROGNAME), tailargs=tailargs 887 ) 888 ) 889 890 by_groups = {} 891 for si in self._switches_by_func.values(): 892 if si.group not in by_groups: 893 by_groups[si.group] = [] 894 by_groups[si.group].append(si) 895 896 def switchs(by_groups, show_groups): 897 for grp, swinfos in sorted(by_groups.items(), key=lambda item: item[0]): 898 if show_groups: 899 lgrp = T_(grp) if grp in _switch_groups else grp 900 print(self.COLOR_GROUP_TITLES[grp] | lgrp + ":") 901 902 for si in sorted(swinfos, key=lambda si: si.names): 903 swnames = ", ".join( 904 ("-" if len(n) == 1 else "--") + n 905 for n in si.names 906 if n in self._switches_by_name 907 and self._switches_by_name[n] == si 908 ) 909 if si.argtype: 910 if hasattr(si.argtype, "__name__"): 911 typename = si.argtype.__name__ 912 else: 913 typename = str(si.argtype) 914 argtype = " {}:{}".format(si.argname.upper(), typename) 915 else: 916 argtype = "" 917 prefix = swnames + argtype 918 yield si, prefix, self.COLOR_GROUPS[grp] 919 920 if show_groups: 921 print("") 922 923 sw_width = ( 924 max(len(prefix) for si, prefix, color in switchs(by_groups, False)) + 4 925 ) 926 description_indent = " {0}{1}{2}" 927 wrapper = TextWrapper(width=max(cols - min(sw_width, 60), 50) - 6) 928 indentation = "\n" + " " * (cols - wrapper.width) 929 930 for switch_info, prefix, color in switchs(by_groups, True): 931 help = switch_info.help # @ReservedAssignment 932 if switch_info.list: 933 help += T_("; may be given multiple times") 934 if switch_info.mandatory: 935 help += T_("; required") 936 if switch_info.requires: 937 help += T_("; requires {0}").format( 938 ", ".join( 939 (("-" if len(switch) == 1 else "--") + switch) 940 for switch in switch_info.requires 941 ) 942 ) 943 if switch_info.excludes: 944 help += T_("; excludes {0}").format( 945 ", ".join( 946 (("-" if len(switch) == 1 else "--") + switch) 947 for switch in switch_info.excludes 948 ) 949 ) 950 951 msg = indentation.join( 952 wrapper.wrap(" ".join(l.strip() for l in help.splitlines())) 953 ) 954 955 if len(prefix) + wrapper.width >= cols: 956 padding = indentation 957 else: 958 padding = " " * max(cols - wrapper.width - len(prefix) - 4, 1) 959 print(description_indent.format(color | prefix, padding, color | msg)) 960 961 if self._subcommands: 962 gc = self.COLOR_GROUP_TITLES["Sub-commands"] 963 print(gc | T_("Sub-commands:")) 964 for name, subcls in sorted(self._subcommands.items()): 965 with gc: 966 subapp = subcls.get() 967 doc = subapp.DESCRIPTION if subapp.DESCRIPTION else getdoc(subapp) 968 if self.SUBCOMMAND_HELPMSG: 969 help = doc + "; " if doc else "" # @ReservedAssignment 970 help += self.SUBCOMMAND_HELPMSG.format( 971 parent=self.PROGNAME, sub=name 972 ) 973 else: 974 help = doc if doc else "" # @ReservedAssignment 975 976 msg = indentation.join( 977 wrapper.wrap(" ".join(l.strip() for l in help.splitlines())) 978 ) 979 980 if len(name) + wrapper.width >= cols: 981 padding = indentation 982 else: 983 padding = " " * max(cols - wrapper.width - len(name) - 4, 1) 984 if colors.contains_colors(subcls.name): 985 bodycolor = colors.extract(subcls.name) 986 else: 987 bodycolor = gc 988 989 print( 990 description_indent.format( 991 subcls.name, padding, bodycolor | colors.filter(msg) 992 ) 993 ) 994 995 def _get_prog_version(self): 996 ver = None 997 curr = self 998 while curr is not None: 999 ver = getattr(curr, "VERSION", None) 1000 if ver is not None: 1001 return ver 1002 curr = curr.parent 1003 return ver 1004 1005 @switch( 1006 ["-v", "--version"], 1007 overridable=True, 1008 group="Meta-switches", 1009 help=T_("""Prints the program's version and quits"""), 1010 ) 1011 def version(self): 1012 """Prints the program's version and quits""" 1013 ver = self._get_prog_version() 1014 ver_name = ver if ver is not None else T_("(version not set)") 1015 print("{} {}".format(self.PROGNAME, ver_name)) 1016