Python is one of my favorite languages but over time I have come to find its dynamically typed syntax to have its pitfalls. This is doubly so in bigger and more complex projects. Using Mypy, a great static type checker for python, and the typing module has been a boon to the quality of my python projects.
Recently I started a new Django project and wanted to seamlessly integrate mypy into my development workflow. The steps I followed were:
- Install
mypy
- Install
django-stubs
(type hints for django) - Configure django-stubs mypy plugin
- Add a custom check to django so
runserver
,migrate
, andcheck
commands will run mypy
Installing mypy and django-stubs
Both of these projects are available on PyPi and can easily be installed with pip. However, at the time of this article django-stubs 0.12.1 didn't work correctly with my installation and I had to use the master branch from github.
pip install mypy
pip instal -e git+https://github.com/mkurnikov/django-stubs#egg=django-stubs
However in my case I used pipenv and only want these dependencies installed in a dev environment so my install looked like so:
pipenv install mypy --dev
pipenv install -e git+https://github.com/mkurnikov/django-stubs#egg=django-stubs --dev
Configure django-stubs mypy plugin
The django-stubs project exposes an optional mypy plugin that allows for some more specific
configuration. To enable the plugin I created a default mypy.ini
configuration file at the
top-level of my project added the plugin to the config.
mypy.ini
[mypy]
plugins = mypy_django_plugin.main
When I ran mypy against my project it all worked great except that it was now throwing errors
because I did not specify attributes on the model fields. I didn't want to do this as it felt
redundant since the model field was quite specific already, e.g. name = models.CharField()
. The
django-stubs mypy plugin allows us to disable this check if we add it to mypy_django.ini
, e.g.
mypy_django.ini
[mypy_django_plugin]
ignore_missing_model_attributes = True
I didn't like having two separate ini files for configuring mypy and ideally wanted all the
settings in mypy.ini
. Thankfully, the plugin allows you to override the environment variable
MYPY_DJANGO_CONFIG
to set the config file. Since I am using pipenv this was quite easy, I
created a .env
file to set this variable and then updated mypy.ini` with the django-stubs mypy
settings.
.env
MYPY_DJANGO_CONFIG=./mypy.ini
mypy.ini
[mypy]
plugins = mypy_django_plugin.main
[mypy_django_plugin]
ignore_missing_model_attributes = True
Finally, I was getting errors on migrations so I wanted to ignore errors on the migrations. To do so I added another section to my config to ignore those files. The final config looks like this:
mypy.ini
[mypy]
plugins = mypy_django_plugin.main
[mypy_django_plugin]
ignore_missing_model_attributes = True
[mypy-*.migrations.*]
ignore_errors = True
For more mypy and django-stubs mypy plugin configurations, please check the respective documentation.
Setup a Django mypy check
Finally, I didn't want to have to constantly run mypy <myproject>
everytime I made changes to my
and wanted this to happen automatically. Rather than use a thirdparty watcher program, I wanted
this to be integrated into django somehow, ideally into the runserver
command as it was
already part of my development workflow and already has auto-reloading functionality.
Thankfully, this is possible with the django check framework.
I created a new checks.py
to contain my new mypy check. The check has to be registered early in
the project so I import it into my project's __init__.py
file. Also, since I'm using pipenv and
only install mypy in my dev environment, I want to make sure my check only gets imported if mypy is
available. The result looks like this:
init.py
import importlib.util
mypy_package = importlib.util.find_spec("mypy")
if mypy_package:
from .checks import mypy
For the check, I use the mypy api to programatically run the check and then parse the results. The
mypy results are in a string format so I parse them with a simple regex and then build a django
CheckMessage
object for each error. I then pass the results to the check framework for django to
manage.
import re
from typing import List
from django.core.checks import register
from django.core.checks.messages import CheckMessage, DEBUG, INFO, WARNING, ERROR
from django.conf import settings
from mypy import api
# The check framework is used for multiple different kinds of checks. As such, errors
# and warnings can originate from models or other django objects. The `CheckMessage`
# requires an object as the source of the message and so we create a temporary object
# that simply displays the file and line number from mypy (i.e. "location")
class MyPyErrorLocation:
def __init__(self, location):
self.location = location
def __str__(self):
return self.location
@register()
def mypy(app_configs, **kwargs) -> List:
print("Performing mypy checks...\n")
# By default run mypy against the whole database everytime checks are performed.
# If performance is an issue then `app_configs` can be inspected and the scope
# of the mypy check can be restricted
mypy_args = [settings.BASE_DIR]
results = api.run([settings.BASE_DIR])
error_messages = results[0]
if not error_messages:
return []
# Example: myproject/checks.py:17: error: Need type annotation for 'errors'
pattern = re.compile("^(.+\d+): (\w+): (.+)")
errors = []
for message in error_messages.rstrip().split("\n"):
parsed = re.match(pattern, message)
if not parsed:
continue
location = parsed.group(1)
mypy_level = parsed.group(2)
message = parsed.group(3)
level = DEBUG
if mypy_level == "note":
level = INFO
elif mypy_level == "warning":
level = WARNING
elif mypy_level == "error":
level = ERROR
else:
print(f"Unrecognized mypy level: {mypy_level}")
errors.append(CheckMessage(level, message, obj=MyPyErrorLocation(location)))
return errors
Finally, all I need to do is run python manage.py runserver
as usual and I have mypy seamlessly
integrated. This is what it looks like in action:
Watching for file changes with StatReloader
Performing system checks...
Performing mypy checks...
Exception in thread django-main-thread:
Traceback (most recent call last):
File "/usr/lib/python3.6/threading.py", line 916, in _bootstrap_inner
self.run()
File "/usr/lib/python3.6/threading.py", line 864, in run
self._target(*self._args, **self._kwargs)
File "/home/ralph/.local/share/virtualenvs/myproject--HcI8Ois/lib/python3.6/site-packages/django/utils/autoreload.py", line 54, in wrapper
fn(*args, **kwargs)
File "/home/ralph/.local/share/virtualenvs/myproject--HcI8Ois/lib/python3.6/site-packages/django/core/management/commands/runserver.py", line 117, in inner_run
self.check(display_num_errors=True)
File "/home/ralph/.local/share/virtualenvs/myproject--HcI8Ois/lib/python3.6/site-packages/django/core/management/base.py", line 436, in check
raise SystemCheckError(msg)
django.core.management.base.SystemCheckError: SystemCheckError: System check identified some issues:
ERRORS:
myproject/checks.py:29: Incompatible types in assignment (expression has type "int", variable has type "str")