Last active
January 28, 2018 23:00
-
-
Save Aran-Fey/3f2731b79eb48d1dbfa5fed7e3b639de to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import sys | |
import inspect | |
import re | |
def called_with_wrong_args(func, locals=None, exc_info=None): | |
""" | |
Finds out whether an exception was raised because invalid arguments were passed to a function. | |
:param func: The function whose call may have failed | |
:param locals: The locals of the scope where the function was called. Optional if this function is called in the same scope. | |
:param exc_info: The exception information, as returned by `sys.exc_info`. Optional inside of the `except:` block. | |
:returns True if the exception was raised because the function call failed, False if the exception was raised for any other reason | |
""" | |
def function_name(func, default=None): | |
# decorators have their __name__ overwritten with that of the wrapped function, | |
# so we'll try to grab the original name from the function's code object | |
try: | |
return func.__code__.co_name | |
except AttributeError: | |
try: | |
return func.__name__ | |
except AttributeError: | |
return default | |
def compare(tb, func): | |
if hasattr(func, '__code__'): | |
# we'll identify python functions by their code object | |
return tb.tb_frame.f_code is func.__code__ | |
else: | |
# builtin functions don't have code objects, so we'll fall back to | |
# identifying them based on their name | |
funcname = function_name(func) | |
if funcname is not None: | |
return tb.tb_frame.f_code.co_name == funcname | |
# if this "function" is actually an object with a __call__ method, it | |
# doesn't have a name of its own | |
return compare(tb, func.__call__) | |
# creating these variables so the `del` statements at the end are guaranteed to work | |
exc_info, call_frame, tb = None, None, None | |
try: | |
if exc_info is None: | |
exc_info = sys.exc_info() | |
exc_type, exc_value, tb = exc_info | |
if exc_type is not TypeError: | |
return False | |
# if the function is a decorator, collect all the wrapped functions | |
functions = [func] | |
while hasattr(func, '__wrapped__'): | |
func = func.__wrapped__ | |
functions.append(func) | |
# find the frame where func was called | |
if locals is None: | |
call_frame = inspect.currentframe().f_back | |
else: | |
# traverse the call stack until we find the frame with those locals | |
call_frame = inspect.currentframe().f_back | |
while call_frame.f_locals is not locals: | |
call_frame = call_frame.f_back | |
# traverse the traceback until we arrive at the frame where the function was called | |
while tb.tb_frame is not call_frame: | |
tb = tb.tb_next | |
# if all of the wrapped functions appear in the traceback, then the call was successful | |
missing = functions.copy() | |
while tb is not None: | |
missing = [func for func in missing if not compare(tb, func)] | |
tb = tb.tb_next | |
if not missing: | |
return False | |
# at this point, we know that not all of the functions appeared in the traceback. | |
# However, that doesn't mean that the function was ever called - it's possible | |
# that this exception is completely unrelated to the function call. | |
# As a failsafe, we'll check the error message. | |
pattern = re.compile(r'(?P<funcname>.*)\(\) (?:' | |
r'takes (?:exactly one argument' | |
r'|1 positional argument' | |
r'|from \d+ to \d+ positional arguments' | |
r'|at most \d+ arguments?' | |
r') (?:but \d+ (?:was|were) given|\(\d+ given\))' | |
r'|missing \d+ required (?:positional|keyword-only) arguments?: .*' | |
r"|got an unexpected keyword argument '.*'" | |
r')' | |
r'|' | |
r"'\w+' is an invalid keyword argument for this function" | |
, flags=re.S) | |
match = re.fullmatch(pattern, str(exc_value)) | |
if match is None: | |
return False | |
else: | |
funcname = match.group('funcname') | |
if funcname is not None and all(function_name(f, '__call__') != funcname for f in functions): | |
return False | |
return True | |
finally: | |
del exc_info | |
del call_frame | |
del tb | |
import pytest | |
import functools | |
def func(exception=None): | |
if exception: | |
raise exception() | |
class Test: | |
def test_no_exception(self): | |
func() | |
assert called_with_wrong_args(func) == False | |
def test_different_exception(self): | |
try: | |
func(ZeroDivisionError) | |
except Exception: | |
assert called_with_wrong_args(func) == False | |
def test_typeerror(self): | |
try: | |
func(TypeError) | |
except TypeError: | |
assert called_with_wrong_args(func) == False | |
def test_wrong_call(self): | |
try: | |
func(1, 2, 3) | |
except TypeError: | |
assert called_with_wrong_args(func) == True | |
def test_wrong_call_of_callable_object(self): | |
class Callable: | |
def __call__(self): | |
pass | |
callable_object = Callable() | |
try: | |
callable_object(1, 2, 3, 4, 5) | |
except TypeError: | |
assert called_with_wrong_args(callable_object) == True | |
def test_wrong_call_in_callable_object(self): | |
class Callable: | |
def __call__(self): | |
func(1, 2, 3, 4, 5) | |
callable_object = Callable() | |
try: | |
callable_object() | |
except TypeError: | |
assert called_with_wrong_args(func) == True | |
def test_wrong_call_in_decorator(self): | |
def decorator(f): | |
@functools.wraps(f) | |
def deco(*args, **kwargs): | |
return f(*args, **kwargs) | |
return deco | |
deco_func = decorator(func) | |
try: | |
deco_func(1, 2, 3, 4, 5) | |
except TypeError: | |
assert called_with_wrong_args(deco_func) == True | |
def test_wrong_decorator_call(self): | |
def decorator(f): | |
@functools.wraps(f) | |
def deco(arg): # decorator accepts only limited arguments | |
return f(arg) | |
return deco | |
deco_func = decorator(func) | |
try: | |
deco_func(1, 2, 3, 4, 5) | |
except TypeError: | |
assert called_with_wrong_args(deco_func) == True | |
def test_wrong_call_in_different_function(self): | |
def different_function(): | |
func(1, 2, 3) | |
try: | |
different_function() | |
except TypeError: | |
assert called_with_wrong_args(func) == True | |
def test_wrong_call_in_different_frame(self): | |
try: | |
func(1, 2, 3) | |
except TypeError: | |
def do_assertion(locals): | |
assert called_with_wrong_args(func, locals) == True | |
do_assertion(locals()) | |
def test_recursive_call(self): | |
def recurse(countdown): | |
if countdown: | |
recurse(countdown - 1) | |
else: | |
recurse(1, 2, 3, 4, 5) | |
try: | |
recurse(3) | |
except TypeError: | |
assert called_with_wrong_args(recurse) == False | |
def test_wrong_call_builtin(self): | |
try: | |
int(1, 2, 3, 4, 5) | |
except TypeError: | |
assert called_with_wrong_args(int) == True | |
def test_wrong_builtin_call_in_func_with_same_name(self): | |
def int(): | |
import builtins | |
try: | |
builtins.int(1, 2, 3, 4, 5) | |
except TypeError: | |
assert called_with_wrong_args(builtins.int) == False | |
int() | |
def test_wrong_call_of_different_function(self): | |
try: | |
int(1, 2, 3) | |
except TypeError: | |
assert called_with_wrong_args(func) == False | |
def test_typeerror_builtin(self): | |
try: | |
int([]) | |
except TypeError: | |
assert called_with_wrong_args(int) == False | |
# === failing tests below === | |
# There's literally nothing we can do to make this work with a bad decorator. Since | |
# it has no __wrapped__ attribute, it's impossible to detect that it's a decorator | |
# and not just another function. | |
@pytest.mark.xfail | |
def test_wrong_bad_decorator_call(self): | |
def decorator(f): | |
def deco(*args, **kwargs): | |
return f(*args, **kwargs) | |
return deco | |
deco_func = decorator(func) | |
try: | |
deco_func(1, 2, 3, 4, 5) | |
except TypeError: | |
assert called_with_wrong_args(deco_func) == True | |
# Well... | |
@pytest.mark.xfail | |
def test_purposely_misleading_exception(self): | |
try: | |
raise TypeError("func() got an unexpected keyword argument 'foobar'") | |
except TypeError: | |
assert called_with_wrong_args(func) == False | |
pytest.main([__file__]) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment