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