File: rangetest.py

##############################################################################

# the following works only for postional arguments,
# and assumes they always appear at the same position
# in every call; they cannot be passed by keyword name,
# and we don't suport **args keywords in calls because
# this can invalidate the positions declared in the
# decorator;  these examples all run under 2.6 and 3.0;


def rangetest(*argchecks):                  # validate positional arg ranges
    def onDecorator(func):
        if not __debug__:                   # True if "python -O main.py args.."
            return func                     # no-op: call original directly
        else:                               # else wrapper while debugging
            def onCall(*args):
                for (ix, low, high) in argchecks:
                    if args[ix] < low or args[ix] > high:
                        errmsg = 'Argument %s not in %s..%s' % (ix, low, high)
                        raise TypeError(errmsg)
                return func(*args)
            return onCall
    return onDecorator


#if __name__ == '__main__':                 # to make this a library 
print(__debug__)                            # True if no -O python cmdline arg                   
                                            # False if "python -O main.py args" 

@rangetest((1, 0, 120))                     # person = rangetest(..)(person)
def person(name, age):
    print('%s is %s years old' % (name, age))

@rangetest([0, 1, 12], [1, 1, 31], [2, 0, 2009])
def birthday(M, D, Y):
    print('birthday = {0}/{1}/{2}'.format(M, D, Y))


class Person:
    """
    revisit the Person class to validate argument
    """
    def __init__(self, name, job, pay):
        self.job  = job
        self.pay  = pay
                                          # arg 0 is the self instance here
    @rangetest([1, 0.0, 1.0])             # giveRaise = rangetest(..)(giveRaise)
    def giveRaise(self, percent):
        self.pay = int(self.pay * (1 + percent))


# comment lines raise TypError unless "python -O" on shell command line

person('Bob Smith', 45)                   # really runs onCall(..) with state 
#person('Bob Smith', 200)                 # or person() if -O cmd line argument

birthday(5, 31, 1963)
#birthday(5, 32, 1963)

sue = Person('Sue Jones', 'dev', 100000)
sue.giveRaise(.10)                        # really runs onCall(self, .10)
print(sue.pay)                            # or giveRaise(self, .10) if -O
#sue.giveRaise(1.10)
#print(sue.pay)


################################################################################

# the following tests either positional or keyword args,
# or both, but does not support an argument being passed
# in both modes at different calls: once decorated, the
# argument to test must always appear at the same position
# in every call, or always be passed by keyword in every call;


def rangetest(*argchecks, **kargchecks):    # validate keyword arg ranges too
    def onDecorator(func):
        if not __debug__:                   # True if "python -O main.py args.."
            return func                     # no-op: call original directly
        else:                               # else wrapper while debugging
            def onCall(*args, **kargs):
                for (ix, low, high) in argchecks:
                    if args[ix] < low or args[ix] > high:
                        errmor = 'Argument #%s not in %s..%s' % (ix, low, high)
                        raise TypeError(error)
                    
                for (key, (low, high)) in kargchecks.items():
                    if kargs[key] < low or kargs[key] > high:
                        error = 'Argument "%s" not in %s..%s' % (key, low, high)
                        raise TypeError(error)
                    
                return func(*args, **kargs)
            return onCall
    return onDecorator


#if __name__ == '__main__':                 # to make this a library 
print(__debug__)                            # True if no -O python cmdline arg                   
                                            # False if "python -O main.py args" 

@rangetest((1, 0, 120))                     # person = rangetest(..)(person)
def person(name, age):
    print('%s is %s years old' % (name, age))

@rangetest(M=(1, 12), D=(1, 31), Y=(0, 2009))
def birthday(M, D, Y):
    print('birthday = {0}/{1}/{2}'.format(M, D, Y))

@rangetest((0, 0, 999999999), height=(0.0, 7.0))     # sss always by position
def record(ssn, category, height):                   # height always by keyword
    print('record: %s, %s' % (ssn, category))        # category not checked

class Person:
    """
    revisit the Person class to validate argument
    """
    def __init__(self, name, job, pay):
        self.job  = job
        self.pay  = pay
                                          # giveRaise = rangetest(..)(giveRaise)
    @rangetest((1, 0.0, 1.0))             # assume percent passed by position
    def giveRaise(self, percent):
        self.pay = int(self.pay * (1 + percent))
                                          
    @rangetest(percent=(0.0, 1.0))        # same, but assume passed by keyword
    def giveRaise2(self, percent, bonus=.10):
        self.pay = int(self.pay * (1 + percent + bonus))


# comment lines raise TypError unless "python -O" on shell command line

person('Bob Smith', 45)                    # really runs onCall(..) with state 
#person('Bob Smith', 200)                  # or person() if -O cmd line argument

birthday(M=5, D=31, Y=1963)
birthday(Y=1963, M=5, D=31)
#birthday(M=5, D=32, Y=1963)

record(123456789, 'spam', height=5.5)
record(123456789, height=5.5, category='spam')
#record(1234567899, height=5.5, category='spam')
#record(123456789, height=99, category='spam')

sue = Person('Sue Jones', 'dev', 100000)
sue.giveRaise(.10)                        # really runs onCall(self, .10)
print(sue.pay)                            # or giveRaise(self, .10) if -O
sue.giveRaise2(percent=.10)
print(sue.pay)
#sue.giveRaise2(percent=1.10)
#print(sue.pay)


###############################################################################

# the following allows checked arguments to be passed either
# by position or keyword name, and automatically maps non-keyword
# arguments to their position by matching against the expected
# argument names grabed from the function's code object; expected
# arguments remaining after removing actual keyword arguments are
# assumed to be passed by postion; their index in the expected
# arguments list gives their position in the actual positionals

# caveat: does not skip testing for arguments that are allowed
# default in the call


def rangetest(**argchecks):                 # validate ranges for both arg types
    def onDecorator(func):
        if not __debug__:                   # True if "python -O main.py args.."
            return func                     # no-op: call original directly
        else:                               # else wrapper while debugging
            import sys
            code = func.__code__ if sys.version_info[0] == 3 else func.func_code
            allargs = code.co_varnames[:code.co_argcount]
            
            def onCall(*pargs, **kargs):
                positionals = list(allargs) 
                for argname in kargs:
                    # for all passed by name, remove
                    positionals.remove(argname)
                    
                for (argname, (low, high)) in argchecks.items():
                    # for all args to be checked
                    if argname in kargs:
                        # was passed by name
                        if kargs[argname] < low or kargs[argname] > high:
                            errmsg = 'Argument "{0}" not in {1}..{2}'
                            errmsg = errmsg.format(argname, low, high)
                            raise TypeError(errmsg)
                    else:
                        # was passed by position
                        position = positionals.index(argname)
                        if pargs[position] < low or pargs[position] > high:
                            errmsg = 'Argument "{0}" not in {1}..{2}'
                            errmsg = errmsg.format(argname, low, high)
                            raise TypeError(errmsg)

                return func(*pargs, **kargs)    # okay: run original call
            return onCall
    return onDecorator


#if __name__ == '__main__':                 # to make this a library 
print(__debug__)                            # True if no -O python cmdline arg                   
                                            # False if "python -O main.py args" 

@rangetest(age=(0, 120))                    # person = rangetest(..)(person)
def person(name, age):
    print('%s is %s years old' % (name, age))

@rangetest(M=(1, 12), D=(1, 31), Y=(0, 2009))
def birthday(M, D, Y):
    print('birthday = {0}/{1}/{2}'.format(M, D, Y))

@rangetest(ssn=(0, 999999999), height=(0.0, 7.0))
def record(ssn, category, height):                   # args by name or position
    print('record: %s, %s' % (ssn, category))        # category not checked

class Person:
    """
    revisit the Person class to validate argument
    """
    def __init__(self, name, job, pay):
        self.job  = job
        self.pay  = pay
                                          # giveRaise = rangetest(..)(giveRaise)
    @rangetest(percent=(0.0, 1.0))        # percent passed by name or position
    def giveRaise(self, percent):
        self.pay = int(self.pay * (1 + percent))
                                          

# comment lines raise TypError unless "python -O" on shell command line

person('Bob Smith', 45)                    # really runs onCall(..) with state
person(age=45, name='Bob Smith')
#person('Bob Smith', 200)                  # or person() if -O cmd line argument
#person('Bob Smith', age=200)
#person(age=200, name='Bob Smith')

birthday(M=5, D=31, Y=1963)
birthday(Y=1963, M=5, D=31)
birthday(5, 31, Y=1963)
birthday(5, D=31, Y=1963)
birthday(5, Y=1963, D=31)
#birthday(5, 32, 1963)
#birthday(5, 32, Y=1963)
#birthday(5, D=32, Y=1963)
#birthday(5, Y=1963, D=32)
#birthday(M=5, D=32, Y=1963)

record(123456789, 'spam', height=5.5)
record(123456789, height=5.5, category='spam')
#record(1234567899, height=5.5, category='spam')
#record(123456789, height=99, category='spam')

sue = Person('Sue Jones', 'dev', 100000)
sue.giveRaise(.10)                        # really runs onCall(self, .10)
print(sue.pay)                            # or giveRaise(self, .10) if -O
sue.giveRaise(percent=.10)
print(sue.pay)
#sue.giveRaise(1.10)
#sue.giveRaise(percent=1.10)
#print(sue.pay)



# caveat: does not skip testing for arguments that are allowed
# default in the call:


@rangetest(a=(1,10), b=(1,10), c=(1, 10), d=(1, 10))
def omitargs(a, b=7, c=8, d=9):
    print(a, b, c, d)

omitargs(1, 2, 3, 4)
#omitargs(1, 2, 3)          # FAILS! - index out of range, pargs[position]
#omitargs(1, 2)
#omitargs(1)
omitargs(1, 2, 3, d=4)
#omitargs(1, 2, d=4)
#omitargs(1, d=4)
#omitargs(a=1, d=4)
#omitargs(d=4, a=1)
#omitargs(1, b=2)
#omitargs(1, b=2, d=4)


################################################################################

# final: skip tests for arguments that were defaulted (omitted) in call,
# by assuming the the first N actual arguments in *pargs must match the
# first N argument names in the list of all expected arguments


trace = True

def rangetest(**argchecks):                 # validate ranges for both+defaults
    def onDecorator(func):
        if not __debug__:                   # True if "python -O main.py args.."
            return func                     # no-op: call original directly
        else:                               # else wrapper while debugging
            import sys
            code = func.__code__ if sys.version_info[0] == 3 else func.func_code
            allargs  = code.co_varnames[:code.co_argcount]
            funcname = func.__name__
            
            def onCall(*pargs, **kargs):
                # all pargs match first N args by position
                # the rest must be in kargs or omitted defaults
                positionals = list(allargs)
                positionals = positionals[:len(pargs)]

                for (argname, (low, high)) in argchecks.items():
                    # for all args to be checked
                    if argname in kargs:
                        # was passed by name
                        if kargs[argname] < low or kargs[argname] > high:
                            errmsg = '{0} argument "{1}" not in {2}..{3}'
                            errmsg = errmsg.format(funcname, argname, low, high)
                            raise TypeError(errmsg)

                    elif argname in positionals:
                        # was passed by position
                        position = positionals.index(argname)
                        if pargs[position] < low or pargs[position] > high:
                            errmsg = '{0} argument "{1}" not in {2}..{3}'
                            errmsg = errmsg.format(funcname, argname, low, high)
                            raise TypeError(errmsg)
                    else:
                        # assume not passed: default
                        if trace:
                            print('Argument "{0}" defaulted'.format(argname))

                return func(*pargs, **kargs)    # okay: run original call
            return onCall
    return onDecorator


#if __name__ == '__main__':                 # to make this a library 
print(__debug__)                            # True if no -O python cmdline arg                   
                                            # False if "python -O main.py args" 

@rangetest(age=(0, 120))                    # person = rangetest(..)(person)
def person(name, age):
    print('%s is %s years old' % (name, age))

@rangetest(M=(1, 12), D=(1, 31), Y=(0, 2009))
def birthday(M, D, Y):
    print('birthday = {0}/{1}/{2}'.format(M, D, Y))


@rangetest(ssn=(0, 999999999), height=(0.0, 7.0))
def record(ssn, category, height):                   # args by name or position
    print('record: %s, %s' % (ssn, category))        # category not checked

@rangetest(a=(1, 10), b=(1, 10), c=(1, 10), d=(1, 10))
def omitargs(a, b=7, c=8, d=9):
    print(a, b, c, d)                                # skip omitted defaults

class Person:
    """
    revisit the Person class to validate argument
    """
    def __init__(self, name, job, pay):
        self.job  = job
        self.pay  = pay
                                          # giveRaise = rangetest(..)(giveRaise)
    @rangetest(percent=(0.0, 1.0))        # percent passed by name or position
    def giveRaise(self, percent):
        self.pay = int(self.pay * (1 + percent))
                                          

# comment lines raise TypError unless "python -O" on shell command line

person('Bob Smith', 45)                    # really runs onCall(..) with state
person(age=45, name='Bob Smith')
#person('Bob Smith', 200)                  # or person() if -O cmd line argument
#person('Bob Smith', age=200)
#person(age=200, name='Bob Smith')

birthday(M=5, D=31, Y=1963)
birthday(Y=1963, M=5, D=31)
birthday(5, 31, Y=1963)
birthday(5, D=31, Y=1963)
birthday(5, Y=1963, D=31)
#birthday(5, 32, 1963)
#birthday(5, 32, Y=1963)
#birthday(5, D=32, Y=1963)
#birthday(5, Y=1963, D=32)
#birthday(M=5, D=32, Y=1963)

record(123456789, 'spam', height=5.5)
record(123456789, height=5.5, category='spam')
#record(1234567899, height=5.5, category='spam')
#record(123456789, height=99, category='spam')

sue = Person('Sue Jones', 'dev', 100000)
sue.giveRaise(.10)                        # really runs onCall(self, .10)
print(sue.pay)                            # or giveRaise(self, .10) if -O
sue.giveRaise(percent=.10)
print(sue.pay)
#sue.giveRaise(1.10)
#sue.giveRaise(percent=1.10)
#print(sue.pay)

omitargs(1, 2, 3, 4)
omitargs(1, 2, 3)
omitargs(1, 2)
omitargs(1)
omitargs(1, 2, 3, d=4)
omitargs(1, 2, d=4)
omitargs(1, d=4)
omitargs(a=1, d=4)
omitargs(d=4, a=1)
omitargs(1, b=2)
omitargs(1, b=2, d=4)
omitargs(a=1)
omitargs(d=8, c=7, b=6, a=1)
omitargs(d=8, c=7, a=1)


#omitargs(1, 2, 3, 11)
#omitargs(1, 2, 11)
#omitargs(1, 11, 3)
#omitargs(11, 2, 3)
#omitargs(1, 11)
#omitargs(11)
#omitargs(1, 2, 3, d=11)
#omitargs(1, 2, 11, d=4)
#omitargs(1, 2, d=11)
#omitargs(1, 11, d=4)
#omitargs(1, d=11)
#omitargs(11, d=4)
#omitargs(d=4, a=11)
#omitargs(1, b=11, d=4)
#omitargs(1, b=2, d=11)
#omitargs(1, b=2, c=11)
#omitargs(1, b=11)
#omitargs(d=8, c=11, b=6, a=1)
#omitargs(d=8, c=7, a=11)


# caveat: invald calls still fail, but at func(*pargs, **kargs)
#
#omitargs()    
#omitargs(d=8, c=7, b=6)

# caveat: still does nothing about * and ** in the
# decorated function, but we probably don't need to care;

def func(a, b=8, *pargs, **kargs):
    print('func:', pargs, kargs)            # jun-2018: 3.X compatible prints

code = func.__code__
print('expected:', code.co_varnames[:code.co_argcount])

def wrap(func):
    def onCall(*pargs, **kargs):
        print('onCall:', pargs, kargs)
        func(*pargs, **kargs)
    return onCall

func = wrap(func)
func(1, 2, 3, 4, x=4, y=5)
#func(1, x=4, y=5)

#==>
#expected: ('a', 'b')
#func: (3, 4) {'y': 5, 'x': 4}
#onCall: (1, 2, 3, 4) {'y': 5, 'x': 4}
#func: (3, 4) {'y': 5, 'x': 4}


# caveat: could also test ranges inside the function or use
# assert, but decorator coding patter supports more complex
# requirements better



[Home page] Books Code Blog Python Author Train Find ©M.Lutz