Python is considered a dynamically-typed language, meaning that the type of a variable can change during runtime if its value is changed to something else. For example:

x = 7
print(type(x)) # prints: <class 'int'>
x = "Hello World"
print(type(x)) # prints: <class 'str'>

In the code above, the variable x contains an integer type value when it is originally set. Afterward, the value is changed to "Hello World", which is a String type value. Ordinarily when we write Python code, we do not need to declare the specific types of the variables; instead the Python interpreter will keep track of the current type internally, based on the values that we assign to the variable.

The opposite of this would be a statically-typed language such as C or Java. In a statically-typed language, the developer must explicitly declare the type when a variable is created, and the type is not able to change simply by changing the value. If the user wants the type to change, they must use a specific conversion or casting function to create a new variable of the desired type.

What is Typing Information?

Type hints are an optional extra bits of code that declare the intended types for function arguments and returns.  Adding type hints to Python code is basically sharing the developers intentions in the code. It is stating something like "the person who wrote this code says that this variable is a String and should always remain a String. If you are writing code that interacts with this it is safe for you to assume this variable will be a String." Since Python is a dynamically-typed language, it is not required for us to declare types like this, but there are some benefits of doing so even though it isn't required.

Benefits of Typing

The Python and CircuitPython interpreters ignore typing information. So it will make no difference when your code is running whether or not you've included types. The real benefits of typing are felt during the development phase, rather than at runtime. Types allow you, the developer, to be more certain about what kinds of values are expected to be contained by variables. This can make it easier to spot bugs in the code before it's executed. For instance:

x = "Hello World"
print(x.lower()) # prints: hello world
x = 7
print(x.lower()) # raises AttributeError

In the code above, the x variable is initially assigned the value "Hello World", which is a String type. In Python, Strings have a function called lower() that returns the lower-cased version of the String. The first print() statement executes successfully. Afterward the value is changed to 7, an integer type which does not contain the lower() function. So when the second print() statement tries to execute, it causes an AttributeError to be raised, which will crash the program if it's not caught. Type hints try to help us spot this type of error earlier, ideally before we've even attempted to run the program at all. IDEs and other programs can parse the code with type hints and attempt to warn us ahead of time when situations like this arise.

In the image above, PyCharm IDE has highlighted part of the code and shows a warning  to the programmer that they are attempting to access the lower() function, which does not exist for the integer type; it's likely to cause an error if it's executed before resolving this problem. PyCharm and other IDEs use the typing information provided to double check our work and warn us when we accidentally try to use a variable in an invalid way.

The Syntax of Type Hints

So now you know what type hints are and why they are beneficial, but how do you actually add type hints to your code? For CircuitPython libraries, we are primarily concerned with types for function arguments and returns. CPython (desktop computer Python) supports other types beyond them. But these are the ones we are focused on right now.

Function Argument Types

To add types to function arguments, we code a colon : after the argument name and then put the type after that. For example:

def unhexlify(hexstr: str):
    # code that would be here isn't relevant to the example
    pass

In the example above, the argument hexstr has its type declared as str, which is short for String. So the type hint is indicating that when you call unhexlify(), you should always pass it a String type variable as the hexstr argument. If there is more than one argument, then each argument should get its own colon and type declaration; if you are providing default values for the arguments, they are coded with a equals sign after the type. Here is another example showing a more complex function with its arguments typed:

def __init__(
        self,
        i2c_bus: busio.I2C,
        address: int = 0x0C,
        gain: int = GAIN_1X,
        resolution: int = RESOLUTION_16,
        filt: int = FILTER_7,
        oversampling: int = OSR_3,
        debug: bool = False,
    ):

In this example, there are 5 integer type arguments, 1 boolean type argument, and 1 busio.I2C object argument. Many of the arguments are given default values of constant variables declared in the class that this comes from. Note that the first argument self does not receive a type.

Function Return Types

To code function return types, we use a hyphen and an angle bracket to make an arrow that points to the type that will be returned by the function. This is coded after the parentheses containing the arguments, and before the colon that indicates the beginning of the functions code definition.

def last_status(self) -> int:
	# code that would be here isn't relevant to the example
    pass

The above function has been declared to return an integer type value. So any code using this function should be expecting to receive an integer value and should not try to treat it as a String or anything else.

One thing to keep in mind while coding Python classes is that the __init__() function always returns the None type. Here is an example of an __init__() function with its return type coded:

def __init__(self, spi: busio.SPI, cs: digitalio.DigitalInOut) -> None:
	# code that would be here isn't relevant to the example
    pass

CircuitPython-Specific Considerations

In CPython ("normal" desktop Python), there is a built-in module called typing which contains some helper classes sometimes used when declaring types. typing was introduced with Python version 3.5. CircuitPython does not currently have this module, so any code that attempts to import it will raise an ImportError if it runs on a CircuitPython microcontroller. This may sound problematic, but it actually ends up working out in our favor. We can catch this exception and ignore it, and since types make no difference at runtime, our code will still work properly. The benefit is that we can use this exception as an indicator that the code is running on a microcontroller and choose to ignore the error without importing any of the other classes used only for typing, which will save some precious RAM on the microcontroller.

try:
	# imports used only for typing
    from typing import Tuple # <- this line causes the error
    
    # any imports below this won't happen if the error gets raised
    from circuitpython_typing import ReadableBuffer
    from busio import I2C
except ImportError:
    pass # ignore the error

In the CircuitPython libraries, we code the imports as shown. The first import should always be from the typing module. The remaining imports after the first one are other classes that may actually exist in CircuitPython or in CircuitPython-compatible libraries such as circuitpython_typing

By putting the typing import first, it will cause the ImportError to be raised before any of the classes that might actually exist get imported, preventing them from being imported and consuming RAM that won't be used when the program executes.

Finding the Correct Types

Now you know why type hints are helpful, and you know the syntax used to code the type hints. But you may be wondering how do you figure out which type an argument or return is supposed to be so that you can code the hint? If you are authoring a brand new library from scratch, you probably will know what types are expected since you are the one writing the code and choosing which things get passed in as arguments and which things get returned from functions.

If you're adding type hints to an existing library, you'll have to put on your detective's hat and look for context clues and other evidence that suggests which type any given argument or return type is supposed to be. 

Common Places to Look

No two libraries or functions are the exact same, so there is no "one size fits all" approach to determining types. However there are a few common places to look around in the code that are likely to provide good clues about the types.

Docstrings

If the code contains docstring comments, this might already have the type listed in them and you can use the type specified in the docstring for the type hint.

def _get_pixel(self, xpos, ypos):
        """
        Get value of a matrix pixel
        :param int xpos: x position
        :param int ypos: y position
        :return: value of pixel in matrix
        :rtype: int
        """
        # code that would be here isn't relevant to the example
        pass

In this function, the docstring comment does document all of the arguments and their types as well as the return type. In this case, the detective work is mostly done for us, we just need to put the types specified by the docstring into our type hints using the syntax noted above.

Here's how it would look after adding the type hints:

def _get_pixel(self, xpos: int, ypos: int) -> int:
        """
        Get value of a matrix pixel
        :param int xpos: x position
        :param int ypos: y position
        :return: value of pixel in matrix
        :rtype: int
        """
        # code that would be here isn't relevant to the example
        pass

Function Definition

If there is no docstring comment, or if it does not contain the argument and return types, then you'll have to dive a bit deeper. The next place to look is inside of the function definition. You're looking for the lines of code that use the argument variables, and then from those lines of code you may be able to determine what the type is meant to be.

def scroll(self, delta_x, delta_y):
        if delta_x < 0:
            shift_x = 0
            xend = self.width + delta_x
            dt_x = 1
        else:
            shift_x = self.width - 1
            xend = delta_x - 1
            dt_x = -1
        if delta_y < 0:
            y = 0
            yend = self.height + delta_y
            dt_y = 1
        else:
            y = self.height - 1
            yend = delta_y - 1
            dt_y = -1

In this function, we see that there are two arguments that we want to find the type for, delta_x and delta_y. When we look in to the definition code for the function, we see a couple of if statements that are comparing delta_x and delta_y to 0 using the less than operator <. We also find some lines of code performing mathematical operations with delta_x and delta_y such as:

xend = delta_x - 1

In this code, it appears that delta_x and delta_y are being treated as numbers. Numbers can be compared to other numbers with greater than or less than, and they can also be added and subtracted to other numbers. None of the numbers shown contain decimals, and there is no division operator being used, so it's a good guess that these numbers are specifically integers which use the abbreviation int for the type. If we found decimals, or if the variable we are trying to type came from the division operator such as:

some_var = 100/3

then it may be more likely that our numbers could be of the float type rather than int. You'll have to look for context clues in the code like this to try to make the most educated guess possible based on the information you find.

Function Usage

If you don't find concrete evidence in the function definition, another place to look is in the code that is calling the function. This might be inside of an example file, or perhaps in another source code file within the library if it is a multi-file package; sometimes it could even be a usage in the same file where it's defined, but just in a different area of code within that file. Try using the find utility with the function in your code editor to search for usages. Some of the more advanced IDEs even provide a specific "Function Usages" menu item when you right click a function name that will parse all of the project files and show you a list of every found usage. Here is an excerpt from an example script:

text_to_show = "Hello world"
text_area = bitmap_label.Label(terminalio.FONT, text=text_to_show)

In this piece of code, we see a variable named text_to_show which has been given the value of "Hello World". We know that "Hello World" is a String because it has quotes around it which is exactly how strings are coded.

Next we see that text_to_show has been passed to the Label() initializer function as an argument named text. So from this, we can conclude that the argument named text has the type of string.

In this case, the variable names also contain a clue: since they both mention the word "text", we know that Strings in Python store text values, so this is further evidence that suggests string is the correct type.

This guide was first published on Jul 31, 2017. It was last updated on 2022-04-21 11:43:19 -0400.

This page (Typing Information) was last updated on May 26, 2022.

Text editor powered by tinymce.