#!/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 ] [func1 argument "incr" not ] [func1 argument "data" not ] [func2 return value not ] [func2 argument "incr" not ] [meth argument "data" not ] [meth argument "front" not ] 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] """