File: class/Extras/Code/OOP/debugtypes.py

#!/usr/bin/python3
"""
============================================================================
debugtypes.py (3.X): function/method call type-testing decorator.
Origin:  January 2015, by M. Lutz (http://www.rmi.net/~lutz). 
License: provided freely but with no warranties of any kind. 

Function decorator that performs type testing on calls to any decorated
function or method, for both arbitrary passed-in arguments and return value.
Types are specified to the decorator by 3.X function annotations (Python
2.X users: mod to use decorator arguments instead).  In the actual call,
arguments may be passed by position or keyword, and defaults may be omitted.

ADAPTED FROM exercise solution code given in Chapter 39, Page 1350, of
book _Learning Python, 5th Edition_.  Adds return type testing, uses
annotations instead of decorator args, and limits the code to this one
specific role only, type testing.  See the book for this code's logic.
Caveat: type testing is not always a Good Thing, and can limit your code!
============================================================================
"""

trace = False
exactmode = False   # TBD: make me a decorator arg? per check?

def typematch(subject, check, exactmode=exactmode):
    if exactmode:
        return type(subject) == check       # this type and no other
    else:
        return isinstance(subject, check)   # this type or a subclass


# the following need func passed in: not in an enclosing scope here

def onArgError(func, argname, kind):
    errfmt = '%s argument "%s" not %s'
    raise TypeError(errfmt % (func.__name__, argname, kind))

def onRetError(func, kind):
    errfmt = '%s return value not %s'
    raise TypeError(errfmt % (func.__name__, kind))


def debugtypes(func):
    """
    -------------------------------------------------------------------------
    Decorator: validate func call's args and return types per annotations;
    onCall is a call proxy and closure that retains func and more in scope;
    this is a no-op if client run with -O: "py[thon] -O main.py args...";
    TBD: should this use exactmatch (above)? proceed with call on errors?
    -------------------------------------------------------------------------
    """
    if not __debug__:                   
        return func
    else:
        code = func.__code__
        expected = list(code.co_varnames[:code.co_argcount])
        argchecks = func.__annotations__.copy()
        retcheck = argchecks.pop('return', 'skip')             # Allow None tests

        def onCall(*pargs, **kargs):                           # Call proxy 
            # Test passed arg types 
            positionals = expected[:len(pargs)]
            for (argname, kind) in argchecks.items():          # For all to test
                if argname in kargs:                           # Passed by name
                    if not typematch(kargs[argname], kind):
                        onArgError(func, argname, kind)

                elif argname in positionals:                   # Passed by posit
                    position = positionals.index(argname)
                    if not typematch(pargs[position], kind):
                        onArgError(func, argname, kind)
                else:                                          # Not passed-dflt
                    if trace:
                        print('Argument "%s" defaulted' % argname)

            # Args ok: call original func, test result type
            result = func(*pargs, **kargs)
            if retcheck != 'skip' and not typematch(result, retcheck):
                onRetError(func, retcheck)

            # All types ok: return call result
            return result

        return onCall   # Rebind func to this object at decoration time


if __name__ == '__main__':
    # Self test
    import sys
    
    def works(test):
        try:    result = test()
        except: print('?<%s>?' % sys.exc_info()[1])    # unexpected fail
        else:   print('(%r)' % result)                 # expected pass
   
    def fails(test):
        try:    result = test()
        except: print('[%s]' % sys.exc_info()[1])      # expected fail
        else:   print('?<%r>?' % result)               # unexpected pass

    @debugtypes
    def func1(data: bytes, incr: int) -> bytes:
        return bytes(byte + incr for byte in data)

    @debugtypes
    def func2(data: bytes, incr: int) -> bytes:
        return ''.join(chr(byte + incr) for byte in data)

    class C:
        def __init__(self, addon):
            self.addon = addon
        @debugtypes
        def meth(self, data: list, front: int):        # skip result test
            return data[:front] + self.addon
            
    works(lambda: func1(b'spam', 1))
    works(lambda: func1(b'spam', incr=1))
    works(lambda: func1(incr=1, data=b'spam'))
    works(lambda: C([8, 9]).meth([1, 2, 3, 4], 2)) 
    
    fails(lambda: func1('spam', 1))
    fails(lambda: func1(b'spam', 'eggs'))
    fails(lambda: func1(incr=1, data='spam'))

    fails(lambda: func2(b'spam', 1))
    fails(lambda: func2(data=b'spam', incr=1.0))
    fails(lambda: C([8, 9]).meth('spam', 2))
    fails(lambda: C([8, 9]).meth([1, 3, 4, 5], front='spam'))


"""
Expected output:

c:\...\Code\encoder>py -3 debugtypes.py
(b'tqbn')
(b'tqbn')
(b'tqbn')
([1, 2, 8, 9])
[func1 argument "data" not <class 'bytes'>]
[func1 argument "incr" not <class 'int'>]
[func1 argument "data" not <class 'bytes'>]
[func2 return value not <class 'bytes'>]
[func2 argument "incr" not <class 'int'>]
[meth argument "data" not <class 'list'>]
[meth argument "front" not <class 'int'>]

c:\...\Code\encoder>py -3 -O debugtypes.py
(b'tqbn')
(b'tqbn')
(b'tqbn')
([1, 2, 8, 9])
[Can't convert 'int' object to str implicitly]
[unsupported operand type(s) for +: 'int' and 'str']
[Can't convert 'int' object to str implicitly]
?<'tqbn'>?
[integer argument expected, got float]
[Can't convert 'list' object to str implicitly]
[slice indices must be integers or None or have an __index__ method]
""" 



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