Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Aran-Fey/3f2731b79eb48d1dbfa5fed7e3b639de to your computer and use it in GitHub Desktop.
Save Aran-Fey/3f2731b79eb48d1dbfa5fed7e3b639de to your computer and use it in GitHub Desktop.
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