Originally published in my blog: https://sobolevn.me/2019/08/typechecking-django-and-drf
As you have already know I love optional static typing. The thing is that sometimes it is not optional, but impossible. Because we have plenty of big untyped projects in Python's ecosystem.
Django and Django-Rest-Framework were two of them. Were. Because now they can be typed! Let me introduce TypedDjango organisation and stubs for django
and drf
.
This is going to be a concise tutorial and getting started guide.
Kudos
I want to say a big "thank you" to @mkurnikov for leading the project and to all contributors who made this possible. You are all awesome!
TLDR
In this article, I am showing how types work with django
and drf
. You can have a look at the result here.
And you can also use wemake-django-template
to start your new projects with everything already configured. It will look exactly like the example project.
Getting started
In this little tutorial, I will show you several features of django-stubs
and djangorestframework-stubs
in action. I hope this will convince you that having someone to doublecheck things after you is a good thing.
You can always refer to the original documentation. All the steps are also covered there.
To start we will need a new project and a clean virtual environment, so we can install our dependencies:
pip install django django-stubs mypy
Then we will need to configure mypy
correctly. It can be split into two steps. First, we configure the mypy
itself:
# setup.cfg
[mypy]
# The mypy configurations: https://mypy.readthedocs.io/en/latest/config_file.html
python_version = 3.7
check_untyped_defs = True
disallow_any_generics = True
disallow_untyped_calls = True
disallow_untyped_decorators = True
ignore_errors = False
ignore_missing_imports = True
implicit_reexport = False
strict_optional = True
strict_equality = True
no_implicit_optional = True
warn_unused_ignores = True
warn_redundant_casts = True
warn_unused_configs = True
warn_unreachable = True
warn_no_return = True
Then we configure django-stubs
plugin:
# setup.cfg
[mypy]
# Appending to `mypy` section:
plugins =
mypy_django_plugin.main
[mypy.plugins.django-stubs]
django_settings_module = server.settings
What do we do here?
- We add a custom
mypy
plugin to help the type checker guess types in some complicated Django-specific situations (like models, queryset, settings, etc) - We also add custom configuration for
django-stubs
to point it to the settings, we use for Django. It will need to import it.
The final result can be found here.
We now have everything installed and configured. Let's type check things!
Typechecking views
Let's start with typing views as it is the easiest thing to do with this plugin.
Here's our simple function-based view:
# server/apps/main/views.py
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
def index(request: HttpRequest) -> HttpResponse:
reveal_type(request.is_ajax)
reveal_type(request.user)
return render(request, 'main/index.html')
Let's run and see what types it is aware of. Note that we might need to modify PYTHONPATH
, so mypy
would be able to import our project:
» PYTHONPATH="$PYTHONPATH:$PWD" mypy server
server/apps/main/views.py:14: note: Revealed type is 'def () -> builtins.bool'
server/apps/main/views.py:15: note: Revealed type is 'django.contrib.auth.models.User'
Let's try to break something:
# server/apps/main/views.py
def index(request: HttpRequest) -> HttpResponse:
return render(request.META, 'main/index.html')
Nope, there's a typo and mypy
will catch it:
» PYTHONPATH="$PYTHONPATH:$PWD" mypy server
server/apps/main/views.py:18: error: Argument 1 to "render" has incompatible type "Dict[str, Any]"; expected "HttpRequest"
It works! Ok, but that is pretty straight-forward. Let's complicate our example a little bit and create a custom model to show how can we type models and querysets.
Typechecking models and queryset
Django's ORM is a killer-feature. It is very flexible and dynamic. It also means that it is hard to type. Let's see some features that are already covered by django-stubs
.
Our model definition:
# server/apps/main/models.py
from django.contrib.auth import get_user_model
from django.db import models
User = get_user_model()
class BlogPost(models.Model):
author = models.ForeignKey(User, on_delete=models.CASCADE)
text = models.TextField()
is_published = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self) -> str:
reveal_type(self.id) # example reveal of all fields in a model
reveal_type(self.author)
reveal_type(self.text)
reveal_type(self.is_published)
reveal_type(self.created_at)
return '<BlogPost {0}>'.format(self.id)
And every field of this model is covered by django-stubs
. Let's see what types are revealed:
» PYTHONPATH="$PYTHONPATH:$PWD" mypy server
server/apps/main/models.py:21: note: Revealed type is 'builtins.int*'
server/apps/main/models.py:22: note: Revealed type is 'django.contrib.auth.models.User*'
server/apps/main/models.py:23: note: Revealed type is 'builtins.str*'
server/apps/main/models.py:24: note: Revealed type is 'builtins.bool*'
server/apps/main/models.py:25: note: Revealed type is 'datetime.datetime*'
Everything looks good! django-stubs
provides a custom mypy
plugin to convert model fields into correct instance types. That's why all types are correctly revealed.
The second big feature of django-stubs
plugin is that we can type QuerySet
:
# server/apps/main/logic/repo.py
from django.db.models.query import QuerySet
from server.apps.main.models import BlogPost
def published_posts() -> 'QuerySet[BlogPost]': # works fine!
return BlogPost.objects.filter(
is_published=True,
)
And here's how it can be checked:
reveal_type(published_posts().first())
# => Union[server.apps.main.models.BlogPost*, None]
We can even annotate querysets with .values()
and .values_list()
calls. This plugin is smart!
I have struggled with annotating methods returning QuerySet
s for several years. This feature solves a big problem for me: no more Iterable[BlogPost]
or List[User]
. I can now use real types.
Typechecking APIs
But, typing views, models, forms, commands, urls, and admin is not all we have. TypedDjango also has typings for djangorestframework
. Let's install and configure it:
pip install djangorestframework djangorestframework-stubs
And we can start to create serializers:
# server/apps/main/serializers.py
from rest_framework import serializers
from server.apps.main.models import BlogPost, User
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['username', 'email']
class BlogPostSerializer(serializers.HyperlinkedModelSerializer):
author = UserSerializer()
class Meta:
model = BlogPost
fields = ['author', 'text', 'is_published', 'created_at']
Views:
# server/apps/main/views.py
from rest_framework import viewsets
from server.apps.main.serializers import BlogPostSerializer
from server.apps.main.models import BlogPost
class BlogPostViewset(viewsets.ModelViewSet):
serializer_class = BlogPostSerializer
queryset = BlogPost.objects.all()
And routers:
# server/apps/main/urls.py
from django.urls import path, include
from rest_framework import routers
from server.apps.main.views import BlogPostViewset, index
router = routers.DefaultRouter()
router.register(r'posts', BlogPostViewset)
urlpatterns = [
path('', include(router.urls)),
# ...
]
It does not even look like something has changed, but everything inside is typed: settings, serializers, viewsets, and routers. It will allow you to incrementally add typings where you need them the most.
Let's try to change queryset = BlogPost.objects.all()
to queryset = [1, 2, 3]
in our views:
» PYTHONPATH="$PYTHONPATH:$PWD" mypy server
server/apps/main/views.py:25: error: Incompatible types in assignment (expression has type "List[int]", base class "GenericAPIView" defined the type as "Optional[QuerySet[Any]]")
No, it won't work! Fix your code!
Conclusion
Typing the framework interfaces is an awesome thing to have. When combined with tools like returns
and mappers
it will allow writing type-safe and declarative business logic wrapped into typed framework interfaces. And to decrease the number of errors in the layer between these two.
Optional gradual static typing also allows you to start fast and add types only when your API is stabilized or go with types-driven development from the very start.
However, django-stubs
and djangorestframework-stubs
are new projects. There are still a lot of bugs, planned features, missing type specs. We welcome every contribution from the community to make the developer tooling in Python truly awesome.