1# -*- coding: utf-8 -*- 2from abc import abstractmethod 3 4from plumbum import local 5from plumbum.cli.i18n import get_translation_for 6from plumbum.lib import getdoc, six 7 8_translation = get_translation_for(__name__) 9_, ngettext = _translation.gettext, _translation.ngettext 10 11 12class SwitchError(Exception): 13 """A general switch related-error (base class of all other switch errors)""" 14 15 pass 16 17 18class PositionalArgumentsError(SwitchError): 19 """Raised when an invalid number of positional arguments has been given""" 20 21 pass 22 23 24class SwitchCombinationError(SwitchError): 25 """Raised when an invalid combination of switches has been given""" 26 27 pass 28 29 30class UnknownSwitch(SwitchError): 31 """Raised when an unrecognized switch has been given""" 32 33 pass 34 35 36class MissingArgument(SwitchError): 37 """Raised when a switch requires an argument, but one was not provided""" 38 39 pass 40 41 42class MissingMandatorySwitch(SwitchError): 43 """Raised when a mandatory switch has not been given""" 44 45 pass 46 47 48class WrongArgumentType(SwitchError): 49 """Raised when a switch expected an argument of some type, but an argument of a wrong 50 type has been given""" 51 52 pass 53 54 55class SubcommandError(SwitchError): 56 """Raised when there's something wrong with sub-commands""" 57 58 pass 59 60 61# =================================================================================================== 62# The switch decorator 63# =================================================================================================== 64class SwitchInfo(object): 65 def __init__(self, **kwargs): 66 for k, v in kwargs.items(): 67 setattr(self, k, v) 68 69 70def switch( 71 names, 72 argtype=None, 73 argname=None, 74 list=False, 75 mandatory=False, 76 requires=(), 77 excludes=(), 78 help=None, 79 overridable=False, 80 group="Switches", 81 envname=None, 82): 83 """ 84 A decorator that exposes functions as command-line switches. Usage:: 85 86 class MyApp(Application): 87 @switch(["-l", "--log-to-file"], argtype = str) 88 def log_to_file(self, filename): 89 handler = logging.FileHandler(filename) 90 logger.addHandler(handler) 91 92 @switch(["--verbose"], excludes=["--terse"], requires=["--log-to-file"]) 93 def set_debug(self): 94 logger.setLevel(logging.DEBUG) 95 96 @switch(["--terse"], excludes=["--verbose"], requires=["--log-to-file"]) 97 def set_terse(self): 98 logger.setLevel(logging.WARNING) 99 100 :param names: The name(s) under which the function is reachable; it can be a string 101 or a list of string, but at least one name is required. There's no need 102 to prefix the name with ``-`` or ``--`` (this is added automatically), 103 but it can be used for clarity. Single-letter names are prefixed by ``-``, 104 while longer names are prefixed by ``--`` 105 106 :param envname: Name of environment variable to extract value from, as alternative to argv 107 108 :param argtype: If this function takes an argument, you need to specify its type. The 109 default is ``None``, which means the function takes no argument. The type 110 is more of a "validator" than a real type; it can be any callable object 111 that raises a ``TypeError`` if the argument is invalid, or returns an 112 appropriate value on success. If the user provides an invalid value, 113 :func:`plumbum.cli.WrongArgumentType` 114 115 :param argname: The name of the argument; if ``None``, the name will be inferred from the 116 function's signature 117 118 :param list: Whether or not this switch can be repeated (e.g. ``gcc -I/lib -I/usr/lib``). 119 If ``False``, only a single occurrence of the switch is allowed; if ``True``, 120 it may be repeated indefinitely. The occurrences are collected into a list, 121 so the function is only called once with the collections. For instance, 122 for ``gcc -I/lib -I/usr/lib``, the function will be called with 123 ``["/lib", "/usr/lib"]``. 124 125 :param mandatory: Whether or not this switch is mandatory; if a mandatory switch is not 126 given, :class:`MissingMandatorySwitch <plumbum.cli.MissingMandatorySwitch>` 127 is raised. The default is ``False``. 128 129 :param requires: A list of switches that this switch depends on ("requires"). This means that 130 it's invalid to invoke this switch without also invoking the required ones. 131 In the example above, it's illegal to pass ``--verbose`` or ``--terse`` 132 without also passing ``--log-to-file``. By default, this list is empty, 133 which means the switch has no prerequisites. If an invalid combination 134 is given, :class:`SwitchCombinationError <plumbum.cli.SwitchCombinationError>` 135 is raised. 136 137 Note that this list is made of the switch *names*; if a switch has more 138 than a single name, any of its names will do. 139 140 .. note:: 141 There is no guarantee on the (topological) order in which the actual 142 switch functions will be invoked, as the dependency graph might contain 143 cycles. 144 145 :param excludes: A list of switches that this switch forbids ("excludes"). This means that 146 it's invalid to invoke this switch if any of the excluded ones are given. 147 In the example above, it's illegal to pass ``--verbose`` along with 148 ``--terse``, as it will result in a contradiction. By default, this list 149 is empty, which means the switch has no prerequisites. If an invalid 150 combination is given, :class:`SwitchCombinationError 151 <plumbum.cli.SwitchCombinationError>` is raised. 152 153 Note that this list is made of the switch *names*; if a switch has more 154 than a single name, any of its names will do. 155 156 :param help: The help message (description) for this switch; this description is used when 157 ``--help`` is given. If ``None``, the function's docstring will be used. 158 159 :param overridable: Whether or not the names of this switch are overridable by other switches. 160 If ``False`` (the default), having another switch function with the same 161 name(s) will cause an exception. If ``True``, this is silently ignored. 162 163 :param group: The switch's *group*; this is a string that is used to group related switches 164 together when ``--help`` is given. The default group is ``Switches``. 165 166 :returns: The decorated function (with a ``_switch_info`` attribute) 167 """ 168 if isinstance(names, six.string_types): 169 names = [names] 170 names = [n.lstrip("-") for n in names] 171 requires = [n.lstrip("-") for n in requires] 172 excludes = [n.lstrip("-") for n in excludes] 173 174 def deco(func): 175 if argname is None: 176 argspec = six.getfullargspec(func).args 177 if len(argspec) == 2: 178 argname2 = argspec[1] 179 else: 180 argname2 = _("VALUE") 181 else: 182 argname2 = argname 183 help2 = getdoc(func) if help is None else help 184 if not help2: 185 help2 = str(func) 186 func._switch_info = SwitchInfo( 187 names=names, 188 envname=envname, 189 argtype=argtype, 190 list=list, 191 func=func, 192 mandatory=mandatory, 193 overridable=overridable, 194 group=group, 195 requires=requires, 196 excludes=excludes, 197 argname=argname2, 198 help=help2, 199 ) 200 return func 201 202 return deco 203 204 205def autoswitch(*args, **kwargs): 206 """A decorator that exposes a function as a switch, "inferring" the name of the switch 207 from the function's name (converting to lower-case, and replacing underscores with hyphens). 208 The arguments are the same as for :func:`switch <plumbum.cli.switch>`.""" 209 210 def deco(func): 211 return switch(func.__name__.replace("_", "-"), *args, **kwargs)(func) 212 213 return deco 214 215 216# =================================================================================================== 217# Switch Attributes 218# =================================================================================================== 219class SwitchAttr(object): 220 """ 221 A switch that stores its result in an attribute (descriptor). Usage:: 222 223 class MyApp(Application): 224 logfile = SwitchAttr(["-f", "--log-file"], str) 225 226 def main(self): 227 if self.logfile: 228 open(self.logfile, "w") 229 230 :param names: The switch names 231 :param argtype: The switch argument's (and attribute's) type 232 :param default: The attribute's default value (``None``) 233 :param argname: The switch argument's name (default is ``"VALUE"``) 234 :param kwargs: Any of the keyword arguments accepted by :func:`switch <plumbum.cli.switch>` 235 """ 236 237 ATTR_NAME = "__plumbum_switchattr_dict__" 238 239 def __init__( 240 self, names, argtype=str, default=None, list=False, argname=_("VALUE"), **kwargs 241 ): 242 self.__doc__ = "Sets an attribute" # to prevent the help message from showing SwitchAttr's docstring 243 if default and argtype is not None: 244 defaultmsg = _("; the default is {0}").format(default) 245 if "help" in kwargs: 246 kwargs["help"] += defaultmsg 247 else: 248 kwargs["help"] = defaultmsg.lstrip("; ") 249 250 switch(names, argtype=argtype, argname=argname, list=list, **kwargs)(self) 251 listtype = type([]) 252 if list: 253 if default is None: 254 self._default_value = [] 255 elif isinstance(default, (tuple, listtype)): 256 self._default_value = listtype(default) 257 else: 258 self._default_value = [default] 259 else: 260 self._default_value = default 261 262 def __call__(self, inst, val): 263 self.__set__(inst, val) 264 265 def __get__(self, inst, cls): 266 if inst is None: 267 return self 268 else: 269 return getattr(inst, self.ATTR_NAME, {}).get(self, self._default_value) 270 271 def __set__(self, inst, val): 272 if inst is None: 273 raise AttributeError("cannot set an unbound SwitchAttr") 274 else: 275 if not hasattr(inst, self.ATTR_NAME): 276 setattr(inst, self.ATTR_NAME, {self: val}) 277 else: 278 getattr(inst, self.ATTR_NAME)[self] = val 279 280 281class Flag(SwitchAttr): 282 """A specialized :class:`SwitchAttr <plumbum.cli.SwitchAttr>` for boolean flags. If the flag is not 283 given, the value of this attribute is ``default``; if it is given, the value changes 284 to ``not default``. Usage:: 285 286 class MyApp(Application): 287 verbose = Flag(["-v", "--verbose"], help = "If given, I'll be very talkative") 288 289 :param names: The switch names 290 :param default: The attribute's initial value (``False`` by default) 291 :param kwargs: Any of the keyword arguments accepted by :func:`switch <plumbum.cli.switch>`, 292 except for ``list`` and ``argtype``. 293 """ 294 295 def __init__(self, names, default=False, **kwargs): 296 SwitchAttr.__init__( 297 self, names, argtype=None, default=default, list=False, **kwargs 298 ) 299 300 def __call__(self, inst): 301 self.__set__(inst, not self._default_value) 302 303 304class CountOf(SwitchAttr): 305 """A specialized :class:`SwitchAttr <plumbum.cli.SwitchAttr>` that counts the number of 306 occurrences of the switch in the command line. Usage:: 307 308 class MyApp(Application): 309 verbosity = CountOf(["-v", "--verbose"], help = "The more, the merrier") 310 311 If ``-v -v -vv`` is given in the command-line, it will result in ``verbosity = 4``. 312 313 :param names: The switch names 314 :param default: The default value (0) 315 :param kwargs: Any of the keyword arguments accepted by :func:`switch <plumbum.cli.switch>`, 316 except for ``list`` and ``argtype``. 317 """ 318 319 def __init__(self, names, default=0, **kwargs): 320 SwitchAttr.__init__( 321 self, names, argtype=None, default=default, list=True, **kwargs 322 ) 323 self._default_value = default # issue #118 324 325 def __call__(self, inst, v): 326 self.__set__(inst, len(v)) 327 328 329# =================================================================================================== 330# Decorator for function that adds argument checking 331# =================================================================================================== 332 333 334class positional(object): 335 """ 336 Runs a validator on the main function for a class. 337 This should be used like this:: 338 339 class MyApp(cli.Application): 340 @cli.positional(cli.Range(1,10), cli.ExistingFile) 341 def main(self, x, *f): 342 # x is a range, f's are all ExistingFile's) 343 344 Or, Python 3 only:: 345 346 class MyApp(cli.Application): 347 def main(self, x : cli.Range(1,10), *f : cli.ExistingFile): 348 # x is a range, f's are all ExistingFile's) 349 350 351 If you do not want to validate on the annotations, use this decorator ( 352 even if empty) to override annotation validation. 353 354 Validators should be callable, and should have a ``.choices()`` function with 355 possible choices. (For future argument completion, for example) 356 357 Default arguments do not go through the validator. 358 359 #TODO: Check with MyPy 360 361 """ 362 363 def __init__(self, *args, **kargs): 364 self.args = args 365 self.kargs = kargs 366 367 def __call__(self, function): 368 m = six.getfullargspec(function) 369 args_names = list(m.args[1:]) 370 371 positional = [None] * len(args_names) 372 varargs = None 373 374 for i in range(min(len(positional), len(self.args))): 375 positional[i] = self.args[i] 376 377 if len(args_names) + 1 == len(self.args): 378 varargs = self.args[-1] 379 380 # All args are positional, so convert kargs to positional 381 for item in self.kargs: 382 if item == m.varargs: 383 varargs = self.kargs[item] 384 else: 385 positional[args_names.index(item)] = self.kargs[item] 386 387 function.positional = positional 388 function.positional_varargs = varargs 389 return function 390 391 392class Validator(six.ABC): 393 __slots__ = () 394 395 @abstractmethod 396 def __call__(self, obj): 397 "Must be implemented for a Validator to work" 398 399 def choices(self, partial=""): 400 """Should return set of valid choices, can be given optional partial info""" 401 return set() 402 403 def __repr__(self): 404 """If not overridden, will print the slots as args""" 405 406 slots = {} 407 for cls in self.__mro__: 408 for prop in getattr(cls, "__slots__", ()): 409 if prop[0] != "_": 410 slots[prop] = getattr(self, prop) 411 mystrs = ("{} = {}".format(name, slots[name]) for name in slots) 412 return "{}({})".format(self.__class__.__name__, ", ".join(mystrs)) 413 414 415# =================================================================================================== 416# Switch type validators 417# =================================================================================================== 418class Range(Validator): 419 """ 420 A switch-type validator that checks for the inclusion of a value in a certain range. 421 Usage:: 422 423 class MyApp(Application): 424 age = SwitchAttr(["--age"], Range(18, 120)) 425 426 :param start: The minimal value 427 :param end: The maximal value 428 """ 429 430 __slots__ = ("start", "end") 431 432 def __init__(self, start, end): 433 self.start = start 434 self.end = end 435 436 def __repr__(self): 437 return "[{:d}..{:d}]".format(self.start, self.end) 438 439 def __call__(self, obj): 440 obj = int(obj) 441 if obj < self.start or obj > self.end: 442 raise ValueError( 443 _("Not in range [{0:d}..{1:d}]").format(self.start, self.end) 444 ) 445 return obj 446 447 def choices(self, partial=""): 448 # TODO: Add partial handling 449 return set(range(self.start, self.end + 1)) 450 451 452class Set(Validator): 453 """ 454 A switch-type validator that checks that the value is contained in a defined 455 set of values. Usage:: 456 457 class MyApp(Application): 458 mode = SwitchAttr(["--mode"], Set("TCP", "UDP", case_sensitive = False)) 459 num = SwitchAttr(["--num"], Set("MIN", "MAX", int, csv = True)) 460 461 :param values: The set of values (strings), or other callable validators, or types, 462 or any other object that can be compared to a string. 463 :param case_sensitive: A keyword argument that indicates whether to use case-sensitive 464 comparison or not. The default is ``False`` 465 :param csv: splits the input as a comma-separated-value before validating and returning 466 a list. Accepts ``True``, ``False``, or a string for the separator 467 """ 468 469 def __init__(self, *values, **kwargs): 470 self.case_sensitive = kwargs.pop("case_sensitive", False) 471 self.csv = kwargs.pop("csv", False) 472 if self.csv is True: 473 self.csv = "," 474 if kwargs: 475 raise TypeError( 476 _("got unexpected keyword argument(s): {0}").format(kwargs.keys()) 477 ) 478 self.values = values 479 480 def __repr__(self): 481 return "{{{0}}}".format( 482 ", ".join(v if isinstance(v, str) else v.__name__ for v in self.values) 483 ) 484 485 def __call__(self, value, check_csv=True): 486 if self.csv and check_csv: 487 return [self(v.strip(), check_csv=False) for v in value.split(",")] 488 if not self.case_sensitive: 489 value = value.lower() 490 for opt in self.values: 491 if isinstance(opt, str): 492 if not self.case_sensitive: 493 opt = opt.lower() 494 if opt == value: 495 return opt # always return original value 496 continue 497 try: 498 return opt(value) 499 except ValueError: 500 pass 501 raise ValueError( 502 "Invalid value: {} (Expected one of {})".format(value, self.values) 503 ) 504 505 def choices(self, partial=""): 506 choices = { 507 opt if isinstance(opt, str) else "({})".format(opt) for opt in self.values 508 } 509 if partial: 510 choices = {opt for opt in choices if opt.lower().startswith(partial)} 511 return choices 512 513 514CSV = Set(str, csv=True) 515 516 517class Predicate(object): 518 """A wrapper for a single-argument function with pretty printing""" 519 520 def __init__(self, func): 521 self.func = func 522 523 def __str__(self): 524 return self.func.__name__ 525 526 def __call__(self, val): 527 return self.func(val) 528 529 def choices(self, partial=""): 530 return set() 531 532 533@Predicate 534def ExistingDirectory(val): 535 """A switch-type validator that ensures that the given argument is an existing directory""" 536 p = local.path(val) 537 if not p.is_dir(): 538 raise ValueError(_("{0} is not a directory").format(val)) 539 return p 540 541 542@Predicate 543def MakeDirectory(val): 544 p = local.path(val) 545 if p.is_file(): 546 raise ValueError( 547 "{} is a file, should be nonexistent, or a directory".format(val) 548 ) 549 elif not p.exists(): 550 p.mkdir() 551 return p 552 553 554@Predicate 555def ExistingFile(val): 556 """A switch-type validator that ensures that the given argument is an existing file""" 557 p = local.path(val) 558 if not p.is_file(): 559 raise ValueError(_("{0} is not a file").format(val)) 560 return p 561 562 563@Predicate 564def NonexistentPath(val): 565 """A switch-type validator that ensures that the given argument is a nonexistent path""" 566 p = local.path(val) 567 if p.exists(): 568 raise ValueError(_("{0} already exists").format(val)) 569 return p 570