How to convert the type of function's arguments in Python at runtime?
Converting the type according to type hints in python
Introduction
Most of us have run into a situation where we have to change the type of a parameter in function; either it is simple str
-> int
or something far more complex. But at some point, we'll be repeating the code too much just for converting the types of the parameter instead of actual logic which drastically increases the code length and decreases readability.
Example:
# Defining a function with Type Hints (I'll explain this below)
def greater(a: int, b: int) -> int:
# Converting from str to int
a = int(a)
b = int(b)
# This is equivalent of
# if a > b:
# return a
# else:
# return b
return a if a > b else
# Taking input
a = input('Enter a number')
b = input('Enter another number')
# Passing the arguements to the function
greatest = greater(a, b)
print(greatest)
It's not that big of a problem for a single function, but assume that you're creating a python module that accepts certain data types, it'd be tiresome for users to convert the data type every time before passing to your module or you'd have to repeat your code just for converting the data types.
What are Type Hints?
Type hints in python is a way of telling python the type of data you'd want to accept and return. It is mostly used in functions, but you can use it while declaring variables as well.
Why use Type Hints?
- Using type hints will provide better autocompletion in your favorite IDE.
- You won't have to check the documentation for knowing the return type and the data type of parameters in a function.
- Type Hints make it easier to write and generate documentation.
Python being a dynamically typed language, type hints don't interfere with the core principles of python. Type hints won't affect the running of the code during runtime. Type hinting as str
and passing int
is perfectly okay.
NOTE: Type hint is not something that executes. Python ignores your type hints while running your code.
How to use Type Hints?
Code without Type Hints
def func_a(a, b):
return a + b
Code with Type Hints
def func_a(a: int, b: int) -> int:
return a + b
Explanation:
a: int
means that the parameter a needs to be aint
type.-> int
means that the functionfunc_a
returns a value with typeint
The typing
python module
This module provides runtime support for type hints. The most fundamental support consists of the types Any, Union, Callable, TypeVar, and Generic.
What is typing.Any
?
Any
is a special type indicating an unconstrained type. Every type is compatible with Any
. Any
is compatible with every type. It is used when you don't know the exact type of data, and the type of data may be anything.
What is typing.Callable
?
Callable
is used to denote something that can be called. Most often, it is used to denote a function. Callable[[int, int], str]
means that the provided function will take 2 integer arguments and return a value with the type of str
. Checkout PEP 677 for more info.
What is typing.TypeVar
?
TypeVar
is used for creating dynamic type hints. It is mainly used by static type checkers and IDE for proper autocompletion. Check out Python Docs for more info.
For a detailed introduction to type hints, see PEP 483.
Converting the type on RunTime
Creating a function for conversion
This is a good solution, but it's only viable if there are only a few parameters in a function.
Example:
import typing as t
# Creating a TypeVar _T
_T = TypeVar('_T')
# Creating a function that converts the type
def convert_type(arg: t.Any, converter: _T) -> _T:
return converter(arg)
# Function that sqaures the given number
def square(number: t.Any) -> int:
# Converting str -> int
number = convert_type(number, int)
return number ** 2
Code Explanation:
- Created a TypeVar (
_T
). You can useTypeVar
for dynamic type hints.convert_type
function takes theconverter
with type_T
and the returns value of type_T
. So if we passstr
inconverter
, the function would showstr
as the return value. - Created the function
convert_type
which acceptsarg
andconverter
as it's 2 parameters. - Created a function square that squares the number. But as the passed value is
str
,convert_type(number, int)
is used to convert the number fromstr
toint
.
The code seems good to use everywhere, but once we increase the number of parameters in the function, it starts to have the same problems like repetition of code and less readability as mentioned above.
Creating a decorator for conversion
What is a decorator?
Python has an interesting feature called decorators to add functionality to an existing code. This is also called metaprogramming because a part of the program tries to modify another part of the program at runtime
.
Simply put, decorators are just a sugar-coated version of using one function on top of another function.
Code Without Decorator
import typing as t
# Creating a function which takes another function
# as the parameter
def make_pretty(func: t.Callable) -> t.Callable:
# Running the passed function after printing
print("I got prettier")
func()
# Defining the actual function
def print_something() -> None:
print('something')
# Passing the actual function inside make_pretty()
make_pretty(print_something)
Code With Decorator
import typing as t
# Creating the decorator make_pretty
# It takes the function automatically as the
# paramater if you use the @decorator syntax
def make_pretty(func: t.Callable) -> t.Callable:
# This inner function takes all the arguments (args)
# and keyword arguments (kwargs) passed to the
# function we decorate. It can be None as well
def inner_function(*args, **kwargs):
# We can do anything we want here
print("I got prettier")
# Finally running the passed function
func(*args, **kwargs)
# Returning the inner function so that
# decorator can use it properly
return inner_function
# using @make_pretty decorates print_something()
# function with make_pretty() function
@make_pretty
def print_something() -> None:
print("something")
# Actually running the function
print_something()
Explanation:
- Usage of
function(*args)
means if we callfunction(1, 2, 3, 4, 5)
, the value of the arguementargs
would be(1, 2, 3, 4, 5)
{Tuple} and vice-versa. - Usage of
function(**kwargs)
means if we callfunction(a=1, b=2, c=3)
, the value of the arguementkwargs
would be{a: 1, b: 2, c: 3}
{Dictionary} and vice-versa. - If nothing is passed, the values of
args
andkwargs
will remainNone
. @
is used to denote the usage of adecorator
.
Getting type hints of a function
For converting the argument
type according to the type hints in runtime, we'll have to get the type hints of the function.
from typing import get_type_hints
def add_1(a: int) -> int:
return a + 1
print(get_type_hints(add_1))
OUTPUT:
{'a': <class 'int'>, 'return': <class 'int'>}
Creating a decorator for type conversion
import typing as t
# Creating a TypeVar for dynamic type hinting in the function
_T = t.TypeVar("_T")
# Creating a function for converting type_conversion
def type_conversion(value: t.Any, value_type: type[_T]) -> _T:
# If the value's type has a function named 'convert_types'
# than we call the function and pass the value to it
if hasattr(value_type, "convert_types"):
return value_type.convert_types(value)
# Returning the value if it's of Any type
elif value_type is t.Any:
return value
# if it doesn't we return the value by forcefully
# trying to convert
return value_type(value)
# Creating a decorator to convert types
def convert_types(func: t.Callable) -> t.Callable:
def inner_function(*args, **kwargs) -> t.Any:
# Getting the type hints of the function
annotations = t.get_type_hints(func).copy()
# Removing the 'return' from the dictioary
# as it is unnecessary right now
annotations.pop("return")
# Getting only the values of annotations for
# looping with the args of the function
args_annotations = annotations.values()
new_args = list(args)
# Looping through the annotations and args
for index, (arg, arg_type) in enumerate(zip(args, args_annotations)):
# If arg is not of the required type, we convert it
if not isinstance(arg, arg_type):
new_args[index] = type_conversion(arg)
for arg_value, (arg_name, arg_type) in zip(kwargs, annotations.items()):
# If arg is not of the required type, we convert it
if not isinstance(arg_value, arg_type):
kwargs[arg_name] = type_conversion(arg_value)
return func(*new_args, **kwargs)
return inner_function
# Creating a simple function to add 1
@convert_types
def add_1(a: int) -> int:
return a + 1
# Passing a string to the function
print(add_1("1234"))
OUTPUT:
1235
Explanation of type checker code
- We created a function
type_conversion
for converting the type of the value according to the passed required type. This function first checks if the type has a methodconvert_types
and if it does, the value is passed onto that method. Else if the value is of typetyping.Any
, only the value is returned else the value is forcefully tried to convert. - The
convert_types
decorator gets the type hints of the function, checks if the type hints are matching with the value types, and it's not, the values' type is changed. zip
function is used to combine 2 iterables. Zipping 2 tuples;(1, 2, 3)
and('a', 'b', 'c')
will produce a zip object with value((1, 'a'), (2, 'b'), (3, 'c'))
.enumerate
function is used to get the value and index while looping through the iterable at once.isinstance
is used to check if the value is an instance of the passed class.
Making CustomClass compatible with type checker
While it may seem like the above type checker only supports the built-in data types in python. It's not actually true. We can make our own classes compatible with the type checker by simply adding a convert_types
@classmethod
.
import typing as t
class MyOwnClass:
@classmethod
def convert_types(cls, value: t.Any) -> 'MyOwnClass':
return str(value)
NOTE: Yes, you can type-hint as strings as well instead of actual objects. You can use
-> 'int'
instead of-> int
as per your use case.
Now you can type-hint your parameters with MyOwnClass
and it'd still work.
When not to use this decorator?
- If you have something complex like
t.Union
types; it'd be better to use pydantic. - If you're writing something that is time-sensitive, it'd be better to stay away from runtime type checking, or this specific implementation of runtime type checking, as it loops 2 times before even executing your function.
Resources for further reading
Conclusion
Python type hints were a game changer to developer experience ever since it was introduced in python 3.5
. Python runtime checking is a fun topic to experiment with and might have a very wide range of use cases. But one must use it with extreme caution as it may introduce unexpected slowdowns and bugs to the code.