Repository: tomerfiliba/plumbum

Issue:
Feature request: Validator on main args
I've been digging around in the src, and I don't see a way to provide validators on the positional input arguments. Since the validators are cleanly implemented, this seems to work pretty well:

```python
def main(self, outfile, *srcfiles):
    outfile = cli.ExistingFile(outfile)
    srcfiles = map(cli.ExistingFile, srcfiles)
```

But is there a better, integrated way of doing this? I can think of two options. First, a python3 only way would be to use annotations, like so:

```python
def main(self, outfile : cli.ExistingFile , *srcfiles : cli.ExistingFile):
```

This has the advantage of being clean and integrated, and looks to be pretty easy to implement without affecting the library usage in Python2 (just try getargspec, and if there's an annotation, that fails, so then getfullargspec works), but has the minor downfall that the application now is Python3 only. Another minor downfall is this is a use of annotations that does not fall under PEP 0484.

Second, a decorator, similar to the switches, but for the main function and built for multiple args (a little like Slots in PyQT):
```python
@cli.main_validators(cli.ExistingFile, cli.ExistingFile)
def main(self, outfile, *srcfiles):
```

A very simple, incomplete implementation to show that this is possible:

```python
import decorator
def main_validator(*types)
    @decorator.decorator
    def main_validator(func, *args, **kargs):
        newargs = []
        for i in range(len(args)):
            if isinstance(args[i], str):
                newargs.append(types[i](args[i]))
            else:
                newargs.append(list(map(types[i],args[i])))
        return func(*newargs, **kargs)
```

Just a suggestion! Thanks for such a great library.

Discussion:

Patch:
diff --git a/docs/cli.rst b/docs/cli.rst
index 559fa94..18e46a5 100644
--- a/docs/cli.rst
+++ b/docs/cli.rst
@@ -198,6 +198,28 @@ these values. Here's an example ::
    All of these convert the argument to a :ref:`local path <guide-paths>`.
 
 
+Positional Arguements
+^^^^^^^^^^^^^^^^^^^^^
+
+You can supply positional argument validators using the ``cli.positional`` decorator. Simply
+pass the validators in the decorator matching the names in the main function. For example::
+
+    class MyApp(cli.Application):
+        @positional(cli.ExistingFile, cli.NonexistantPath)
+        def main(self, infile, *outfiles):
+            "infile is a path, outfiles are a list of paths, proper errors are given"
+
+If you only want to run your application in Python 3, you can also use annotations to
+specify the validators. For example::
+
+    class MyApp(cli.Application):
+        def main(self, infile : cli.ExistingFile, *outfiles : cli.NonexistantPath):
+        "Identical to above MyApp"
+
+Annotations are ignored if the positional decorator is present.
+    
+.. versionadded:: 1.5.0
+
 Repeatable Switches
 ^^^^^^^^^^^^^^^^^^^
 Many times, you would like to allow a certain switch to be given multiple times. For instance,
diff --git a/plumbum/cli/__init__.py b/plumbum/cli/__init__.py
index 7ac0e6e..068aaef 100644
--- a/plumbum/cli/__init__.py
+++ b/plumbum/cli/__init__.py
@@ -1,3 +1,3 @@
-from plumbum.cli.switches import SwitchError, switch, autoswitch, SwitchAttr, Flag, CountOf
+from plumbum.cli.switches import SwitchError, switch, autoswitch, SwitchAttr, Flag, CountOf, positional
 from plumbum.cli.switches import Range, Set, ExistingDirectory, ExistingFile, NonexistentPath, Predicate
 from plumbum.cli.application import Application, ColorfulApplication
diff --git a/plumbum/cli/application.py b/plumbum/cli/application.py
index 3600683..8d7786a 100644
--- a/plumbum/cli/application.py
+++ b/plumbum/cli/application.py
@@ -1,7 +1,6 @@
 from __future__ import division, print_function, absolute_import
 import os
 import sys
-import inspect
 import functools
 from textwrap import TextWrapper
 from collections import defaultdict
@@ -299,7 +298,7 @@ class Application(object):
                 continue
 
             # handle argument
-            val = self._handle_argument(val, swinfo, name)
+            val = self._handle_argument(val, swinfo.argtype, name)
 
             if swinfo.func in swfuncs:
                 if swinfo.list:
@@ -329,7 +328,7 @@ class Application(object):
             if swinfo.func in swfuncs:
                 continue  # skip if overridden by command line arguments
 
-            val = self._handle_argument(envval, swinfo, env)
+            val = self._handle_argument(envval, swinfo.argtype, env)
             envname = "$%s" % (env,)
             if swinfo.list:
                 # multiple values over environment variables are not supported,
@@ -343,18 +342,19 @@ class Application(object):
         return swfuncs, tailargs
 
     @classmethod
-    def autocomplete(self, argv):
+    def autocomplete(cls, argv):
         """This is supplied to make subclassing and testing argument completion methods easier"""
         pass
 
-    def _handle_argument(self, val, swinfo, name):
-        if swinfo.argtype:
+    @staticmethod
+    def _handle_argument(val, argtype, name):
+        if argtype:
             try:
-                return swinfo.argtype(val)
+                return argtype(val)
             except (TypeError, ValueError):
                 ex = sys.exc_info()[1]  # compat
                 raise WrongArgumentType("Argument of %s expected to be %r, not %r:\n    %r" % (
-                    name, swinfo.argtype, val, ex))
+                    name, argtype, val, ex))
         else:
             return NotImplemented
 
@@ -388,20 +388,58 @@ class Application(object):
                 raise SwitchCombinationError("Given %s, the following are invalid %r" %
                     (swfuncs[func].swname, [swfuncs[f].swname for f in invalid]))
 
-        m_args, m_varargs, _, m_defaults = six.getargspec(self.main)
-        max_args = six.MAXSIZE if m_varargs else len(m_args) - 1
-        min_args = len(m_args) - 1 - (len(m_defaults) if m_defaults else 0)
+        m = six.getfullargspec(self.main)
+        max_args = six.MAXSIZE if m.varargs else len(m.args) - 1
+        min_args = len(m.args) - 1 - (len(m.defaults) if m.defaults else 0)
         if len(tailargs) < min_args:
             raise PositionalArgumentsError("Expected at least %d positional arguments, got %r" %
                 (min_args, tailargs))
         elif len(tailargs) > max_args:
             raise PositionalArgumentsError("Expected at most %d positional arguments, got %r" %
-                (max_args, tailargs))
+                (max_args, tailargs))            
+                
+        # Positional arguement validataion
+        if hasattr(self.main, 'positional'):
+            tailargs = self._positional_validate(tailargs, self.main.positional, self.main.positional_varargs, m.args[1:], m.varargs)
+            
+        elif hasattr(m, 'annotations'):
+            args_names = list(m.args[1:])
+            positional = [None]*len(args_names)
+            varargs = None
+            
+            
+             # All args are positional, so convert kargs to positional
+            for item in m.annotations:
+                if item == m.varargs:
+                    varargs = m.annotations[item]
+                else:
+                    positional[args_names.index(item)] = m.annotations[item]
+
+            tailargs = self._positional_validate(tailargs, positional, varargs,
+                                                 m.args[1:], m.varargs)
 
         ordered = [(f, a) for _, f, a in
             sorted([(sf.index, f, sf.val) for f, sf in swfuncs.items()])]
         return ordered, tailargs
 
+    def _positional_validate(self, args, validator_list, varargs, argnames, varargname):
+        """Makes sure args follows the validation given input"""
+        out_args = list(args)
+        
+        for i in range(min(len(args),len(validator_list))):
+            
+            if validator_list[i] is not None:
+                out_args[i] = self._handle_argument(args[i], validator_list[i], argnames[i])
+        
+        if len(args) > len(validator_list):
+            if varargs is not None:
+                out_args[len(validator_list):] = [
+                    self._handle_argument(a, varargs, varargname) for a in args[len(validator_list):]]
+            else:
+                out_args[len(validator_list):] = args[len(validator_list):]
+        
+        return out_args
+
     @classmethod
     def run(cls, argv = None, exit = True):  # @ReservedAssignment
         """
@@ -473,23 +511,8 @@ class Application(object):
         """
 
         inst = cls("")
-        swfuncs = {}
-        for index, (swname, val) in enumerate(switches.items(), 1):
-            switch = getattr(cls, swname)
-            swinfo = inst._switches_by_func[switch._switch_info.func]
-            if isinstance(switch, CountOf):
-                p = (range(val),)
-            elif swinfo.list and not hasattr(val, "__iter__"):
-                raise SwitchError("Switch %r must be a sequence (iterable)" % (swname,))
-            elif not swinfo.argtype:
-                # a flag
-                if val not in (True, False, None, Flag):
-                    raise SwitchError("Switch %r is a boolean flag" % (swname,))
-                p = ()
-            else:
-                p = (val,)
-            swfuncs[swinfo.func] = SwitchParseInfo(swname, p, index)
-
+        
+        swfuncs = inst._parse_kwd_args(switches)
         ordered, tailargs = inst._validate_args(swfuncs, args)
         for f, a in ordered:
             f(inst, *a)
@@ -508,6 +531,26 @@ class Application(object):
 
         return inst, retcode
 
+    def _parse_kwd_args(self, switches):
+        """Parses keywords (positional arguments), used by invoke."""
+        swfuncs = {}
+        for index, (swname, val) in enumerate(switches.items(), 1):
+            switch = getattr(type(self), swname)
+            swinfo = self._switches_by_func[switch._switch_info.func]
+            if isinstance(switch, CountOf):
+                p = (range(val),)
+            elif swinfo.list and not hasattr(val, "__iter__"):
+                raise SwitchError("Switch %r must be a sequence (iterable)" % (swname,))
+            elif not swinfo.argtype:
+                # a flag
+                if val not in (True, False, None, Flag):
+                    raise SwitchError("Switch %r is a boolean flag" % (swname,))
+                p = ()
+            else:
+                p = (val,)
+            swfuncs[swinfo.func] = SwitchParseInfo(swname, p, index)
+        return swfuncs
+
     def main(self, *args):
         """Implement me (no need to call super)"""
         if self._subcommands:
@@ -555,13 +598,13 @@ class Application(object):
         if self.DESCRIPTION:
             print(self.COLOR_DISCRIPTION[self.DESCRIPTION.strip() + '\n'])
 
-        m_args, m_varargs, _, m_defaults = six.getargspec(self.main)
-        tailargs = m_args[1:]  # skip self
-        if m_defaults:
-            for i, d in enumerate(reversed(m_defaults)):
+        m = six.getfullargspec(self.main)
+        tailargs = m.args[1:]  # skip self
+        if m.defaults:
+            for i, d in enumerate(reversed(m.defaults)):
                 tailargs[-i - 1] = "[%s=%r]" % (tailargs[-i - 1], d)
-        if m_varargs:
-            tailargs.append("%s..." % (m_varargs,))
+        if m.varargs:
+            tailargs.append("%s..." % (m.varargs,))
         tailargs = " ".join(tailargs)
 
         with self.COLOR_USAGE:
diff --git a/plumbum/cli/switches.py b/plumbum/cli/switches.py
index e3fc915..2512b77 100644
--- a/plumbum/cli/switches.py
+++ b/plumbum/cli/switches.py
@@ -1,7 +1,8 @@
-import inspect
 from plumbum.lib import six, getdoc
 from plumbum import local
 
+from abc import abstractmethod
+
 
 class SwitchError(Exception):
     """A general switch related-error (base class of all other switch errors)"""
@@ -133,7 +134,7 @@ def switch(names, argtype = None, argname = None, list = False, mandatory = Fals
 
     def deco(func):
         if argname is None:
-            argspec = six.getargspec(func)[0]
+            argspec = six.getfullargspec(func).args
             if len(argspec) == 2:
                 argname2 = argspec[1]
             else:
@@ -252,10 +253,95 @@ class CountOf(SwitchAttr):
     def __call__(self, inst, v):
         self.__set__(inst, len(v))
 
+#===================================================================================================
+# Decorator for function that adds argument checking
+#===================================================================================================
+
+
+
+class positional(object):
+    """
+    Runs a validator on the main function for a class.    
+    This should be used like this:
+    
+    class MyApp(cli.Application):
+        @cli.positional(cli.Range(1,10), cli.ExistingFile)
+        def main(self, x, *f):
+            # x is a range, f's are all ExistingFile's)
+    
+    Or, Python 3 only:
+    
+    class MyApp(cli.Application):
+        def main(self, x : cli.Range(1,10), *f : cli.ExistingFile):
+            # x is a range, f's are all ExistingFile's)
+        
+        
+    If you do not want to validate on the annotations, use this decorator (
+    even if empty) to override annotation validation.
+    
+    Validators should be callable, and should have a .choices() function with
+    possible choices. (For future argument completion, for example)
+    
+    Default arguments do not go through the validator.
+    
+    #TODO: Check with MyPy
+    
+    """
+    
+    def __init__(self, *args, **kargs):
+        self.args = args
+        self.kargs = kargs
+    
+    def __call__(self, function):
+        m = six.getfullargspec(function)
+        args_names = list(m.args[1:])
+            
+        positional = [None]*len(args_names)
+        varargs = None
+        
+        for i in range(min(len(positional),len(self.args))):
+            positional[i] = self.args[i]
+        
+        if len(args_names) + 1 == len(self.args):
+            varargs = self.args[-1]
+        
+         # All args are positional, so convert kargs to positional
+        for item in self.kargs:
+            if item == m.varargs:
+                varargs = self.kargs[item]
+            else:
+                positional[args_names.index(item)] = self.kargs[item]
+            
+        function.positional = positional
+        function.positional_varargs = varargs
+        return function
+    
+class Validator(six.ABC):
+    __slots__ = ()
+    
+    @abstractmethod
+    def __call__(self, obj):
+        "Must be implemented for a Validator to work"
+        
+    def choices(self, partial=""):
+        """Should return set of valid choices, can be given optional partial info"""
+        return set()
+        
+    def __repr__(self):
+        """If not overridden, will print the slots as args"""
+        
+        slots = {}
+        for cls in self.__mro__:
+            for prop in getattr(cls, "__slots__", ()):
+                if prop[0] != '_':
+                    slots[prop] = getattr(self, prop)
+        mystrs = ("{0} = {1}".format(name, slots[name]) for name in slots)
+        return "{0}({1})".format(self.__class__.__name__, ", ".join(mystrs))
+    
 #===================================================================================================
 # Switch type validators
 #===================================================================================================
-class Range(object):
+class Range(Validator):
     """
     A switch-type validator that checks for the inclusion of a value in a certain range.
     Usage::
@@ -266,6 +352,8 @@ class Range(object):
     :param start: The minimal value
     :param end: The maximal value
     """
+    __slots__ = ("start", "end")
+    
     def __init__(self, start, end):
         self.start = start
         self.end = end
@@ -276,18 +364,21 @@ class Range(object):
         if obj < self.start or obj > self.end:
             raise ValueError("Not in range [%d..%d]" % (self.start, self.end))
         return obj
+    def choices(self, partial=""):
+        # TODO: Add partial handling
+        return set(range(self.start, self.end+1))
 
-class Set(object):
+class Set(Validator):
     """
     A switch-type validator that checks that the value is contained in a defined
     set of values. Usage::
 
         class MyApp(Application):
-            mode = SwitchAttr(["--mode"], Set("TCP", "UDP", case_insensitive = False))
+            mode = SwitchAttr(["--mode"], Set("TCP", "UDP", case_sensitive = False))
 
     :param values: The set of values (strings)
-    :param case_insensitive: A keyword argument that indicates whether to use case-sensitive
-                             comparison or not. The default is ``True``
+    :param case_sensitive: A keyword argument that indicates whether to use case-sensitive
+                             comparison or not. The default is ``False``
     """
     def __init__(self, *values, **kwargs):
         self.case_sensitive = kwargs.pop("case_sensitive", False)
@@ -302,6 +393,9 @@ class Set(object):
         if obj not in self.values:
             raise ValueError("Expected one of %r" % (list(self.values.values()),))
         return self.values[obj]
+    def choices(self, partial=""):
+        # TODO: Add case sensitive/insensitive parital completion
+        return set(self.values)
 
 class Predicate(object):
     """A wrapper for a single-argument function with pretty printing"""
@@ -311,6 +405,8 @@ class Predicate(object):
         return self.func.__name__
     def __call__(self, val):
         return self.func(val)
+    def choices(self, partial=""):
+        return set()
 
 @Predicate
 def ExistingDirectory(val):
diff --git a/plumbum/colorlib/styles.py b/plumbum/colorlib/styles.py
index 73c7938..1c5e4f3 100644
--- a/plumbum/colorlib/styles.py
+++ b/plumbum/colorlib/styles.py
@@ -10,7 +10,6 @@ With the ``Style`` class, any color can be directly called or given to a with st
 from __future__ import print_function
 import sys
 import os
-import platform
 import re
 from copy import copy
 from plumbum.colorlib.names import color_names, color_html
@@ -127,9 +126,6 @@ class Color(ABC):
         self.exact = True
         'This is false if the named color does not match the real color'
 
-        self.use_color = True
-        'This is a toggle for color (or max representation)'
-
         if None in (g,b):
             if not r_or_color:
                 return
diff --git a/plumbum/lib.py b/plumbum/lib.py
index 35c3fc6..77c6f06 100644
--- a/plumbum/lib.py
+++ b/plumbum/lib.py
@@ -27,14 +27,13 @@ class six(object):
     A light-weight version of six (which works on IronPython)
     """
     PY3 = sys.version_info[0] >= 3
-    ABC = ABCMeta('ABC', (object,), {'__module__':__name__})
+    ABC = ABCMeta('ABC', (object,), {'__module__':__name__, '__slots__':()})
 
-    # Be sure to use named-tuple access, so that different order doesn't affect usage
+    # Be sure to use named-tuple access, so that usage is not affected
     try:
-        getargspec = staticmethod(inspect.getargspec)
+        getfullargspec = staticmethod(inspect.getfullargspec)
     except AttributeError:
-        getargspec = staticmethod(lambda func: inspect.getfullargspec(func)[:4])
-
+        getfullargspec = staticmethod(inspect.getargspec) # extra fields will not be available
 
     if PY3:
         integer_types = (int,)


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 22d2f01..674074f 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,11 +1,11 @@
-import sys
 import unittest
-from plumbum import cli
 import time
 
 from plumbum import cli, local
 from plumbum.cli.terminal import ask, choose, hexdump, Progress
-from plumbum.lib import six, captured_stdout, StringIO
+from plumbum.lib import captured_stdout, ensure_skipIf
+
+ensure_skipIf(unittest)
 
 class TestApp(cli.Application):
     @cli.switch(["a"])
@@ -28,6 +28,8 @@ class TestApp(cli.Application):
         self.eggs = "lalala"
         self.eggs = old
         self.tailargs = args
+        
+
 
 class Geet(cli.Application):
     debug = cli.Flag("--debug")
diff --git a/tests/test_validate.py b/tests/test_validate.py
new file mode 100644
index 0000000..42f60ea
--- /dev/null
+++ b/tests/test_validate.py
@@ -0,0 +1,107 @@
+# -*- coding: utf-8 -*-
+from __future__ import print_function, division
+import unittest
+from plumbum import cli
+from plumbum.lib import captured_stdout
+from plumbum.lib import six
+
+class TestValidator(unittest.TestCase):
+    def test_named(self):
+        class Try(object):
+            @cli.positional(x=abs, y=str)
+            def main(selfy, x, y):
+                pass
+
+        self.assertEqual(Try.main.positional, [abs, str])
+        self.assertEqual(Try.main.positional_varargs, None)
+
+    def test_position(self):
+        class Try(object):
+            @cli.positional(abs, str)
+            def main(selfy, x, y):
+                pass
+
+        self.assertEqual(Try.main.positional, [abs, str])
+        self.assertEqual(Try.main.positional_varargs, None)
+
+    def test_mix(self):
+        class Try(object):
+            @cli.positional(abs, str, d=bool)
+            def main(selfy, x, y, z, d):
+                pass
+
+        self.assertEqual(Try.main.positional, [abs, str, None, bool])
+        self.assertEqual(Try.main.positional_varargs, None)
+
+    def test_var(self):
+        class Try(object):
+            @cli.positional(abs, str, int)
+            def main(selfy, x, y, *g):
+                pass
+
+        self.assertEqual(Try.main.positional, [abs, str])
+        self.assertEqual(Try.main.positional_varargs, int)
+        
+    def test_defaults(self):
+        class Try(object):
+            @cli.positional(abs, str)
+            def main(selfy, x, y = 'hello'):
+                pass
+            
+        self.assertEqual(Try.main.positional, [abs, str])
+
+
+class TestProg(unittest.TestCase):
+    def test_prog(self):
+        class MainValidator(cli.Application):
+            @cli.positional(int, int, int)
+            def main(self, myint, myint2, *mylist):
+                print(repr(myint), myint2, mylist)        
+        
+        with captured_stdout() as stream:
+            _, rc = MainValidator.run(["prog", "1", "2", '3', '4', '5'], exit = False)
+        self.assertEqual(rc, 0)
+        self.assertEqual("1 2 (3, 4, 5)", stream.getvalue().strip())
+
+
+    def test_failure(self):
+        class MainValidator(cli.Application):
+            @cli.positional(int, int, int)
+            def main(self, myint, myint2, *mylist):
+                print(myint, myint2, mylist)
+        with captured_stdout() as stream:
+            _, rc = MainValidator.run(["prog", "1.2", "2", '3', '4', '5'], exit = False)
+        self.assertEqual(rc, 2)
+        value = stream.getvalue().strip()
+        self.assertTrue("'int'>, not '1.2':" in value)
+        self.assertTrue(" 'int'>, not '1.2':" in value)
+        self.assertTrue('''ValueError("invalid literal for int() with base 10: '1.2'"''' in value)
+
+    def test_defaults(self):
+        class MainValidator(cli.Application):
+            @cli.positional(int, int)
+            def main(self, myint, myint2=2):
+                print(repr(myint), repr(myint2))        
+        
+        with captured_stdout() as stream:
+            _, rc = MainValidator.run(["prog", "1"], exit = False)
+        self.assertEqual(rc, 0)
+        self.assertEqual("1 2", stream.getvalue().strip())
+        
+        with captured_stdout() as stream:
+            _, rc = MainValidator.run(["prog", "1", "3"], exit = False)
+        self.assertEqual(rc, 0)
+        self.assertEqual("1 3", stream.getvalue().strip())
+
+# Unfortionatly, Py3 anotations are a syntax error in Py2, so using exec to add test for Py3
+if six.PY3:
+    exec("""
+class Main3Validator(cli.Application):
+    def main(self, myint:int, myint2:int, *mylist:int):
+        print(myint, myint2, mylist)
+class TestProg3(unittest.TestCase):
+    def test_prog(self):
+        with captured_stdout() as stream:
+            _, rc = Main3Validator.run(["prog", "1", "2", '3', '4', '5'], exit = False)
+        self.assertEqual(rc, 0)
+        self.assertIn("1 2 (3, 4, 5)", stream.getvalue())""")