Overview
So I was playing around with Python decorators the other day (as you do). I always wondered if you could get Python to validate the function parameter types and/or the return type, much like static languages. Some people would say this is useful, whereas others would say it's never necessary due to Python's dynamic nature. I just hacked this up to see if it could be done, and thought it was pretty cool to see what you can do with Python. So I'll leave the politics out of this :-). Feel free to put your thoughts in the comments.
This article assumes that you at least have basic knowledge of decorators, and how they work.
The following code validates the type
of the top level of the function arguments and return types. However it doesn't look at the types below a data structure. For example you could specify that the 2nd argument must be a tuple. However you cannot validate sub-values, such as the following: (<type 'int'>, <type 'str'>)
. If anyone can pull this off, I'd like to see!
So let's get started!
Validator Exceptions
Before we start with the actual code to validate the function parameter and return type
, we'll be adding in some custom exceptions. Note that this is not necessary, I just preferred it to abstract some of the messages, along with creating cleaner code. We'll be creating the following exceptions:
ArgumentValidationError
: When thetype
of an argument to a function is not what it should be.InvalidArgumentNumberError
: When the number of arguments supplied to a function is incorrect.InvalidReturnType
: When the return value is the wrongtype
.
Each exception comes with a custom error message, so the data for the message is passed to the constructor method: __init__()
. Let's take a look at the code:
[python]
class ArgumentValidationError(ValueError):
'''
Raised when the type of an argument to a function is not what it should be.
'''
def __init__(self, arg_num, func_name, accepted_arg_type):
self.error = 'The {0} argument of {1}() is not a {2}'.format(arg_num,
func_name,
accepted_arg_type)
def __str__(self):
return self.error
class InvalidArgumentNumberError(ValueError):
'''
Raised when the number of arguments supplied to a function is incorrect.
Note that this check is only performed from the number of arguments
specified in the validate_accept() decorator. If the validate_accept()
call is incorrect, it is possible to have a valid function where this
will report a false validation.
'''
def __init__(self, func_name):
self.error = 'Invalid number of arguments for {0}()'.format(func_name)
def __str__(self):
return self.error
class InvalidReturnType(ValueError):
'''
As the name implies, the return value is the wrong type.
'''
def __init__(self, return_type, func_name):
self.error = 'Invalid return type {0} for {1}()'.format(return_type,
func_name)
def __str__(self):
return self.error
[/python]
Pretty straight forward. Notice that the ArgumentValidationError
exception requires the parameter arg_num
, to specify the Nth argument that has the wrong type
. This requires an ordinal number: eg. 1st, 2nd, 3rd, etc. So our next step is to create a simple function to convert an int
to an ordinal number.
Ordinal Number Converter
[python]
def ordinal(num):
'''
Returns the ordinal number of a given integer, as a string.
eg. 1 -> 1st, 2 -> 2nd, 3 -> 3rd, etc.
'''
if 10 <= num % 100 < 20:
return '{0}th'.format(num)
else:
ord = {1 : 'st', 2 : 'nd', 3 : 'rd'}.get(num % 10, 'th')
return '{0}{1}'.format(num, ord)
[/python]
We've called it ordinal()
just to keep things simple, where it takes one int
argument. But how does it work? For numbers 6 - 20, the number suffix (in order from 6 - 20) is always "th". However outside of that range:
- If the last number ends with 1, the suffix will be "st".
- If the last number ends with 2, the suffix will be "nd".
- If the last number ends with 3, the suffix will be "rd".
- Otherwise (when the last number is >= 4), the suffix will be "th".
In this case we've used a %
character, which is the modulus
operator. It provides us with the remainder, when divided by a number. In this case, if we divide a number by 10, it will give us the last digit. Note that we could also use str(num)[-1]
. However this is inefficient, and ugly.
Now, onto the actual validators!
Function Parameter Validation
We'll start by showing off the code to the function parameter validator (decorator), and go from there. Both validation functions are very similar, however a bit different.
[python]
def accepts(*accepted_arg_types):
'''
A decorator to validate the parameter types of a given function.
It is passed a tuple of types. eg. (
Note: It doesn't do a deep check, for example checking through a
tuple of types. The argument passed must only be types.
'''
def accept_decorator(validate_function):
# Check if the number of arguments to the validator
# function is the same as the arguments provided
# to the actual function to validate. We don't need
# to check if the function to validate has the right
# amount of arguments, as Python will do this
# automatically (also with a TypeError).
@functools.wraps(validate_function)
def decorator_wrapper(*function_args, **function_args_dict):
if len(accepted_arg_types) is not len(accepted_arg_types):
raise InvalidArgumentNumberError(validate_function.__name__)
# We're using enumerate to get the index, so we can pass the
# argument number with the incorrect type to ArgumentValidationError.
for arg_num, (actual_arg, accepted_arg_type) in enumerate(zip(function_args, accepted_arg_types)):
if not type(actual_arg) is accepted_arg_type:
ord_num = ordinal(arg_num + 1)
raise ArgumentValidationError(ord_num,
validate_function.__name__,
accepted_arg_type)
return validate_function(*function_args)
return decorator_wrapper
return accept_decorator
[/python]
Okay! So what on earth is that? Notice that with regular decorators, there's a function that returns a function. Usually the sub-function will do something either before and/or calling the function to wrap. However in our case, we're using meta-decorators. These take the decorator model one-step further, where they are essentially a decorator with a sub-decorator that accepts the function parameters of the function being checked. Pretty confusing! But once you get it, they're quite straightforward.
We're using the zip()
function, which returns a list of tuples. It lets us iterate over two lists at the same time. In our case, the allowed function arguments and the actual function arguments. The zip()
call is then wrapped by enumerate()
. The enumerate()
function itself returns an enumerate
object. Every time the enumerate.next()
function is called (for example in our for
statement), it returns a tuple
. This is in the form of loop-index, (list-1-element, list-2-element)
. This way we can get the index of the argument, to pass to the exception if needed. The isinstance()
function is then used to compare the types of the required argument, and the actual argument. You could also use if type(accepted_arg) is type(actual_arg)
, however I found this way a bit cleaner syntactically.
One note to consider: After our add_nums()
function has been called, technically the decorator returned the value and not add_nums()
. So if you look at the returned object from add_nums()
, it will actually be the decorator. Therefore it will have a different name, and you will lose your doc string. This may or may not be a big deal, however it's easy to fix. Luckily the functools.wraps()
function comes to the rescue, which is a decorator itself. To use this you must import the functools
module, and add the decorator above your validation decorator.
Return Type Validation
Now onto the return type
validation. Here it is, in all it's glory.
[python]
def returns(*accepted_return_type_tuple):
'''
Validates the return type. Since there's only ever one
return type, this makes life simpler. Along with the
accepts() decorator, this also only does a check for
the top argument. For example you couldn't check
(
In that case you could only check if it was a tuple.
'''
def return_decorator(validate_function):
# No return type has been specified.
if len(accepted_return_type_tuple) == 0:
raise TypeError('You must specify a return type.')
@functools.wraps(validate_function)
def decorator_wrapper(*function_args):
# More than one return type has been specified.
if len(accepted_return_type_tuple) > 1:
raise TypeError('You must specify one return type.')
# Since the decorator receives a tuple of arguments
# and the is only ever one object returned, we'll just
# grab the first parameter.
accepted_return_type = accepted_return_type_tuple[0]
# We'll execute the function, and
# take a look at the return type.
return_value = validate_function(*function_args)
return_value_type = type(return_value)
if return_value_type is not accepted_return_type:
raise InvalidReturnType(return_value_type,
validate_function.__name__)
return return_value
return decorator_wrapper
return return_decorator
[/python]
As you can see, it's similar to the parameter validation function (validate_accept
). However since it doesn't need to iterate all values, we just need to check if the types are the same.
Our Function to Validate
I've decided to use a very simple example function. Because ultimately, no matter how complex it is, it will still work in the same way. No need to make things more complicated than they should be! There's two implemented. One with the correct return type
named add_nums_correct
and another one which returns a str
named add_nums_incorrect
. That way we can test if the return
validator works.
[python]
@accepts(int, int)
@returns(int)
def add_nums_correct(a, b):
'''
Adds two numbers. It accepts two
integers, and returns an integer.
'''
return a + b
@accepts(int, int)
@returns(int)
def add_nums_incorrect(a, b):
'''
Adds two numbers. It accepts two
integers, and returns an integer.
'''
return 'Not an int!'
[/python]
So let's check it out, and see if it works.
[python]
>>> # All good.
>>> print(add_nums_correct(1, 2))
3
>>> # Incorrect argument type (first).
>>> add_nums_correct('foo', 5)
Traceback (most recent call last):
File "Validate Function Parameter and Return Types with Decorators.py", line 196, in
add_nums_correct('foo', 5)
File "Validate Function Parameter and Return Types with Decorators.py", line 120, in decorator_wrapper
accepted_arg_type)
__main__.ArgumentValidationError: The 1st argument of add_nums_correct() is not a
>>>
>>> # Incorrect argument type (second).
>>> add_nums_correct(5, 'bar')
Traceback (most recent call last):
File "Validate Function Parameter and Return Types with Decorators.py", line 200, in
add_nums_correct(5, 'bar')
File "Validate Function Parameter and Return Types with Decorators.py", line 120, in decorator_wrapper
accepted_arg_type)
__main__.ArgumentValidationError: The 2nd argument of add_nums_correct() is not a
>>>
>>> # Incorrect argument number.
>>> add_nums_correct(1, 2, 3, 4)
[/python]