python-course.eu

2. Type Annotations For Functions

By Bernd Klein. Last modified: 13 Jul 2023.

Type annotations supply also a specific syntax to indicate the expected types of function parameters and the return type of functions.

A funny function

A simple Pyhon example of a function with type hints:

%%writefile example.py

def greeting(name: str) -> str:
    return 'Hello ' + name

# We call the function with a string, which is okay:
greeting("World!")  

# an integer is an illegal argument, it should a str:
greeting(3)    

# A bytes string is not the right kind of a string:
greeting(b'Alice')  

def bad_greeting(name: str) -> str:
    return 'Hello ' * name  # Unsupported operand types for * ("str" and "str")

OUTPUT:

Overwriting example.py
!mypy example.py

OUTPUT:

example.py:9: error: Argument 1 to "greeting" has incompatible type "int"; expected "str"
example.py:12: error: Argument 1 to "greeting" has incompatible type "bytes"; expected "str"
example.py:15: error: Unsupported operand types for * ("str" and "str")
Found 3 errors in 1 file (checked 1 source file)

The following Python function of an annotated function shows a slightly more extended function definition:

def greeting(name: str, phrase: str='hello') -> str:
    return phrase + ' ' + name

We can see the annotations of a function by looking at the __annotations__ attribute:

greeting.__annotations__

OUTPUT:

{'name': str, 'phrase': str, 'return': str}

The __defaults__ attribute shows us the default values of the function:

greeting.__defaults__

OUTPUT:

('hello',)

These function annotations are available at runtime through the __annotations__ attribute. Yet, there will be no type checking at runtime. Checks have to be done via MyPy or other type checkers.

Let's use MyPy on the previous example:

%%writefile example.py
def greeting(name: str, phrase: str='Hello') -> str:
    return phrase + ' ' + name

print(greeting('Frank', 'Good evening'))
print(greeting('Olga'))

OUTPUT:

Overwriting example.py
!mypy example.py

OUTPUT:

Success: no issues found in 1 source file

Now a type annotation example with a type violation:

%%writefile example.py
def greeting(name: str, phrase: str='Hello') -> str:
    return phrase + ' ' + name

print(greeting('Frank', 42))

OUTPUT:

Overwriting example.py
!mypy example.py

OUTPUT:

example.py:4: error: Argument 2 to "greeting" has incompatible type "int"; expected "str"
Found 1 error in 1 file (checked 1 source file)
%%writefile example.py
from typing import List
def greet_all(names: List[str]) -> None:
    for name in names:
        print('Hello ' + name)

names: List[str]
names = ["Alice", "Bob", "Charlie"]

greet_all(names)   

OUTPUT:

Overwriting example.py
!mypy example.py

OUTPUT:

Success: no issues found in 1 source file
!python example.py

OUTPUT:

Hello Alice
Hello Bob
Hello Charlie
%%writefile example.py
from typing import List
Vector = List[float]

def scale(scalar: float, vector: Vector) -> Vector:
    return [scalar * num for num in vector]


v1: Vector
v2: Vector
v1 = [3, 5, 6]
v2 = scale(3.1, v1)

OUTPUT:

Overwriting example.py
!mypy example.py

OUTPUT:

Success: no issues found in 1 source file
!python example.py

Example: Numerical Input

The following example addresses a common scenario encountered when reading an integer value using input. While it works correctly for most cases, there is an issue when a user inputs a value like 5.0. Syntactically, 5.0 is a float, but from a logical standpoint, it could be treated as an integer. When attempting to convert such input to an integer using int(), a ValueError exception is raised.

To accommodate users who would like these float-like inputs to be treated as integers, the example provides a solution. It incorporates a function that handles the conversion process. If the user input can be directly converted to an integer, it returns the integer value. However, if the input is a float, it rounds the number to the nearest integer using round() and then returns it as an integer.

By employing this approach, the example not only addresses the issue of treating inputs like 5.0 as integers but also ensures that all float numbers are converted to integers by rounding them to the nearest whole number.

def get_rounded_input():
    """
    Prompts the user to enter a number and returns an integer if the user 
    enters an integer, or the rounded value as an integer if the user 
    enters a float.

    Returns:
        int: The entered integer or the rounded value of the entered float.
    """
    user_input = input("Please enter a number: ")
    
    try:
        number = int(user_input)  # Try converting to int
        return number
    except ValueError:
        try:
            number = float(user_input)  # Try converting to float
            return round(number)  # Return rounded value as int
        except ValueError:
            return None  # Return None if input cannot be converted to a number

# Example usage:
result = get_rounded_input()
if result is not None:
    print("Input:", result)
else:
    print("Invalid input!")

OUTPUT:

Input: 4

In this code, the function get_rounded_input() prompts the user to enter a number. It first tries to convert the user input to an integer using int(user_input). If the conversion is successful, it returns the integer value.

If the conversion to an integer fails (raises a ValueError), the function tries to convert the user input to a float using float(user_input). If this conversion is successful, it rounds the float value using the round() function and returns the rounded value as an integer.

If both conversion attempts fail, the function returns None to indicate that the input could not be converted to a number.

You can use this function in your code and handle the return value accordingly. In the example usage provided, it checks if the result is not None before printing the input value. If the result is None, it indicates that the input was invalid.

def get_rounded_input() -> int:
    """
    Prompts the user to enter a number and returns an integer if the user 
    enters an integer, or the rounded value as an integer if the user 
    enters a float.

    Returns:
        int: The entered integer or the rounded value of the entered float.
    """
    user_input: str = input("Please enter a number: ")
    
    try:
        number: int = int(user_input)  # Try converting to int
        return number
    except ValueError:
        try:
            number: float = float(user_input)  # Try converting to float
            return round(number)  # Return rounded value as int
        except ValueError:
            return None  # Return None if input cannot be converted to a number

# Example usage:
result: int = get_rounded_input()
if result is not None:
    print("Input:", result)
else:
    print("Invalid input!")

OUTPUT:

Input: 48

Live Python training

instructor-led training course

Enjoying this page? We offer live Python training courses covering the content of this site.

See: Live Python courses overview

Enrol here

Another Example

def calculate_interest(capital: float, interest_rate: float, years: int) -> float:
    """
    Calculates the interest value after a specified number of years 
    based on the capital and interest rate.

    Args:
        capital (float): The initial capital amount.
        interest_rate (float): The interest rate (in decimal form, 
                               e.g., 0.05 for 5%).
        years (int): The number of years.

    Returns:
        float: The interest value after the specified number of years.
    """
    interest: float = capital * interest_rate * years
    return interest

What is "wrong" with the previous code? What if somebody types calls the function with calculate_interest(10000, 2, 10)? Type checkers will not except it. So we use Union to overcome this problem:

%%writefile example.py
from typing import Union

def calculate_interest(capital: Union[int, float], interest_rate: Union[int, float], years: int) -> float:
    """
    Calculates the interest value after a specified number of years 
    based on the capital and interest rate.

    Args:
        capital (Union[int, float]): The initial capital amount.
        interest_rate (Union[int, float]): The interest rate (in decimal 
                   form, e.g., 0.05 for 5%).
        years (int): The number of years.

    Returns:
        float: The interest value after the specified number of years.
    """
    capital = float(capital)
    interest_rate = float(interest_rate)
    interest: float = capital * interest_rate * years
    return interest

print(calculate_interest(10000, 2,  10))

OUTPUT:

Overwriting example.py
!mypy example.py

OUTPUT:

Success: no issues found in 1 source file
!python example.py

OUTPUT:

200000.0

Another Example

A function to convert degress Celsius to Fahrenheit.

%%writefile example.py
from typing import Union
def celsius_to_fahrenheit(celsius: Union[int, float]) -> Union[int, float]:
    """
    Converts degrees Celsius to Fahrenheit.

    Args:
        celsius (Union[int, float]): The temperature in degrees Celsius.

    Returns:
        Union[int, float]: The temperature in degrees Fahrenheit.
    """
    fahrenheit: Union[int, float] = celsius * 9/5 + 32
    return fahrenheit


for c in [23.5, 19]:
    print(c, celsius_to_fahrenheit(c))

OUTPUT:

Overwriting example.py
!mypy example.py

OUTPUT:

Success: no issues found in 1 source file
%%writefile example.py
from typing import Union

def celsius_to_fahrenheit(celsius: Union[int, float, str]) -> Union[int, float]:
    """
    Converts degrees Celsius to Fahrenheit.

    Args:
        celsius (Union[int, float, str]): The temperature in degrees Celsius.

    Returns:
        Union[int, float]: The temperature in degrees Fahrenheit.
    """
    if isinstance(celsius, str):
        celsius = float(celsius)  # Convert string to float

    fahrenheit: Union[int, float] = celsius * 9/5 + 32
    return fahrenheit


for c in [23.5, 19]:
    print(c, celsius_to_fahrenheit(c))

OUTPUT:

Overwriting example.py
!mypy  example.py

OUTPUT:

Success: no issues found in 1 source file

PEP 604 proposes overloading the | operator on types to allow writing Union[X, Y] as X | Y, and allows it to appear in isinstance and issubclass calls.

%%writefile example.py

def celsius_to_fahrenheit(celsius: int | float | str) -> int | float:
    """
    Converts degrees Celsius to Fahrenheit.

    Args:
        celsius (Union[int, float, str]): The temperature in degrees Celsius.

    Returns:
        Union[int, float]: The temperature in degrees Fahrenheit.
    """
    if isinstance(celsius, str):
        celsius = float(celsius)  # Convert string to float

    fahrenheit: int | float = celsius * 9/5 + 32
    return fahrenheit


for c in [23.5, 19]:
    print(c, celsius_to_fahrenheit(c))

OUTPUT:

Overwriting example.py
!/home/bernd/anaconda3/envs/py3.10/bin/mypy example.py

OUTPUT:

Success: no issues found in 1 source file
!/home/bernd/anaconda3/envs/py3.10/bin/python example.py

OUTPUT:

23.5 74.3
19 66.2
%%writefile example.py
celsius: int | float | str

OUTPUT:

Overwriting example.py
!/home/bernd/anaconda3/envs/py3.10/bin/mypy example.py

OUTPUT:

Success: no issues found in 1 source file
!mypy --version

OUTPUT:

mypy 0.761
!/home/bernd/anaconda3/envs/py3.10/bin/mypy --version

OUTPUT:

mypy 0.981 (compiled: yes)

Live Python training

instructor-led training course

Enjoying this page? We offer live Python training courses covering the content of this site.

See: Live Python courses overview

Enrol here

Functions with Function Parameters

def mapping(func, values):
    result = []
    for value in values:
        result.append(func(value))
    return result
%%writefile example.py
from typing import Callable, Iterable, List, TypeVar, Union

def celsius_to_fahrenheit(celsius: int | float) -> int | float:
    fahrenheit: int | float = celsius * 9/5 + 32
    return fahrenheit


T = TypeVar('T')
U = TypeVar('U')

def mapping(func: Callable[[T], U], values: Iterable[T]) -> List[U]:
    """
    Applies a given function to each element of the iterable and returns a list of the results.

    Args: 
        func (Callable[[T], U]): The function to apply to each element.
        values (Iterable[T]): The iterable containing the elements to be processed.

    Returns:
        List[U]: A list containing the results of applying the function to each element.
    """
    result: List[U] = []
    value: T
    for value in values:
        result.append(func(value))
    return result

numbers: List[Union[int, float]] = [5, 9.4, 12, 4.8]
print(mapping(celsius_to_fahrenheit, numbers))

OUTPUT:

Overwriting example.py
!/home/bernd/anaconda3/envs/py3.10/bin/mypy example.py

OUTPUT:

Success: no issues found in 1 source file
!!/home/bernd/anaconda3/envs/py3.10/bin/python example.py

OUTPUT:

['[41.0, 48.92, 53.6, 40.64]']

Example: Compose Function

%%writefile example.py
from typing import Callable, TypeVar

T = TypeVar('T')
U = TypeVar('U')
V = TypeVar('V')

def compose_functions(f1: Callable[[T], U], f2: Callable[[U], V]) -> Callable[[T], V]:
    """
    Returns a new function that applies f2 to the result of f1(x).

    Args:
        f1 (Callable[[T], U]): The first function.
        f2 (Callable[[U], V]): The second function.

    Returns:
        Callable[[T], V]: The composed function.
    """
    def composed_function(x: T) -> V:
        return f2(f1(x))

    return composed_function


def square(x: int) -> int:
    return x ** 2

def add_one(x: int) -> int:
    return x + 1

result_function = compose_functions(square, add_one)
result = result_function(5)
print(result)  # Output: 26

OUTPUT:

Overwriting example.py
!mypy example.py

OUTPUT:

Success: no issues found in 1 source file

Live Python training

instructor-led training course

Enjoying this page? We offer live Python training courses covering the content of this site.

See: Live Python courses overview

Enrol here

Callable

Frameworks expecting callback functions of specific signatures might be type hinted using Callable[[Arg1Type, Arg2Type], ReturnType].

from collections.abc import Callable

def feeder(get_next_item: Callable[[], str]) -> None:
    # Body
    pass

def async_query(on_success: Callable[[int], None],
                on_error: Callable[[int, Exception], None]) -> None:
    # Body
    pass

async def on_update(value: str) -> None:
    # Body
    pass
    
callback: Callable[[str], str] = on_update

It is possible to declare the return type of a callable without specifying the call signature by substituting a literal ellipsis for the list of arguments in the type hint: Callable[..., ReturnType].

Live Python training

instructor-led training course

Enjoying this page? We offer live Python training courses covering the content of this site.

See: Live Python courses overview

Enrol here