How to Use Type Checking for Existing Python Code Bases

October 2, 2024 by
How to Use Type Checking for Existing Python Code Bases
Bart Vanbrabant

At Inmanta, Python is the primary programming language to develop our service orchestrator. While we love Python and believe it is a fantastic programming language, it does have some drawbacks. The absence of type information in the code can make it more challenging and error-prone to maintain and develop a code base. 

In this blog post, we will explore Python type annotations, explain how we use them in our product, discuss the problems with existing code bases, and propose a potential solution to ease the adoption. 

Python type annotations 

One of the many reasons we use Python at Inmanta is the ability to annotate arguments of a function with a value. This feature is used to annotate the type of plugins so that our compiler can interact safely with our DSL using code in the Python domain. For example: 

@plugin 
def split(string_list: "string", delim: "string") -> "string[]": 
    """ 
​   Split the given string into a list 
    :param string_list: The list to split into parts 
    :param delim: The delimiter to split the text by
    """ 
    return string_list.split(delim) 

The Python community also uses this feature to annotate Python code with type information. Multiple type checkers can perform static type checks using these annotations. This check occurs during development and adds no overhead at runtime. Let's take a look at this code snippet from realpython.com. 

pi: float = 3.142 
def circumference(radius: float) -> float: 
return 2 * pi * radius 

When calling circumference with None, a type checker like mypy will warn you: 

Argument 1 to "circumference" has incompatible type "None"; expected 
"float"  [arg-type] 

At Inmanta, we started annotating our code base with types many years ago to benefit from static typing: 

  • Annotated code provides more information, especially on API boundaries between components, and formally specifies the contract between distinct parts of the code base. 
  • It helps find new bugs at development time, particularly for boundary conditions not always covered by the test suite. An example is a value that can be None. 

Existing code bases 

We added many type annotations in several sprints and implemented a policy for our developers that any new code should have type annotations. This quickly brought us to over 90% type completeness, as reported by mypy. 

Most code still contains some type errors, some components more than others. At this point the law of diminishing returns comes into play: type completeness continues to increase, but it requires a significant effort and often involves breaking our API. 

Because we still have errors, we cannot enforce type correctness in our CI.  Because CI is not enforcing type correctness, developers can inadvertently introduce new type errors. To prevent this, we initially created some helpers that allowed developers to create a baseline and then use diff to detect any new type errors or regressions in existing typing. While this process works, it is not automated. Therefore, we have recently started experimenting with mypy-baseline, a tool that performs a similar function to our helpers but better.   

How we use mypy baseline 

We first create a baseline with mypy-baseline sync that stores the current mypy output in the current repo in a hidden file named .mypy-baseline. The result of the baseline is a summary of the errors that you allow in the baseline: 

mypy app.py | mypy-baseline sync   
Found 1 error in 1 file (checked 1 source file) 
The baseline file contains something like this: 
app.py:0: error: Argument 1 to "circumference" has incompatible 
type "None"; expected "float"  [arg-type] 

Our CI uses mypy-baseline to filter out any existing errors. It shows a clear summary of all errors, including any newly introduced errors. The goal is to avoid introducing any new type errors. Any new errors will cause the CI to fail, preventing the pull request from being merged.

mypy app.py | mypy-baseline filter 
app.py:6: error: Argument 1 to "circumference" has incompatible type
"None"; expected "float"  [arg-type] 
Found 1 error in 1 file (checked 1 source file) 
total errors: 
  fixed: 0 
  new: 1 
  unresolved: 0 
errors by error code: 
  arg-type                   1  +1 

Your changes introduced new violations. 
Please, resolve the violations above before moving forward. 
Mypy is your friend. 

There is not always a simple solution, and we might decide to allow new errors. A new baseline will accomplish this. But these new errors will show up in the diff of the pull request so that for both the developer and reviewers it is explicit that a new error is introduced. 

When a merge conflict appears or the filter is not correct, a new baseline fixes this. And here again, any changes in the baseline require explicit approval of the reviewers. 

A tool for gradual type checking adoption 

Static type checkers like mypy add another tool to your toolbox to build high quality software. However, it can be daunting to add this to existing code bases.  The mypy-baseline team deserves a round of applause. Their work has significantly improved the quality of our Python development by making type checking more accessible and practical to enforcing it into our existing code base.

How to Use Type Checking for Existing Python Code Bases
Bart Vanbrabant October 2, 2024
Share this post
Tags