File: 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]
"""