valberg.dk/content/django-view-decorator.md

285 lines
11 KiB
Markdown
Raw Normal View History

Title: Bringing Locality of Behaviour to Django Views and URLs
2023-06-21 12:00:48 +00:00
Date: 2023-06-21
2023-06-21 20:31:46 +00:00
Status: published
Tags: django, views, locality-of-behaviour
Slug: bringing-locality-of-behaviour-to-django-views-and-urls
Authors: Víðir Valberg Guðmundsson
Summary: Introducing django-view-decorator, a Django package which brings Locality of Behaviour to your Views and URLs
---
It seems that "The Location of Behaviour principle" (shortened as LoB) is gaining traction these days. This has given me the urge to try to influence the direction of Django to bring more LoB to the connection between views and URLs.
2023-06-20 22:05:53 +00:00
But first, what is "LoB"? The principle is coined by the author of HTMX in [a short, but great, essay](https://htmx.org/essays/locality-of-behaviour/). The principle states:
> The behaviour of a unit of code should be as obvious as possible by looking only at that unit of code
Given a very simple view:
:::python
# views.py
def foo(request: HttpRequest) -> HttpResponse:
2023-06-21 12:00:48 +00:00
return HttpResponse("foo")
It is not apparent how to access the view with HTTP. To see what URL the view is tied to we have to look at urls.py:
:::python
# urls.py
from django.urls import path
from .views import foo
urlspatterns = [
path("foo/", foo, name="foo")
]
2023-06-20 22:05:53 +00:00
Carlton Gibson [mentions this](https://youtu.be/_3oGI4RC52s?t=315) in is talk at DjangoCon Europe 2023, and why this means that he often puts view code in the same file as his URLs.
2023-06-20 22:05:53 +00:00
But why this disconnect? Other frameworks, like Flask and FastAPI use a rather simple "pattern" using a decorator which puts the URL information where the view is defined:
:::python
# flask_example.py
from flask import Flask
app = Flask(__name__)
@app.route("foo/")
def foo():
return "bar"
Just by looking at the decorator tied to the `foo` function we know that we can access it via `foo/`.
Another pitfall due to this disconnect is that there is no guarantee that a view has a URL pointing at it.
2023-06-20 22:05:53 +00:00
Can we apply the same pattern to Django? Yes we can!
# Introducing django-view-decorator!
2023-06-21 11:57:10 +00:00
[`django-view-decorator`](https://github.com/valberg/django-view-decorator/) is my attempt to implement a decorator which can be used to apply this pattern to Django views.
2023-06-21 11:27:50 +00:00
The project is very much under development, and it is currently in the "research and development" phase. But it does work!
2023-06-21 11:27:50 +00:00
## Basics
2023-06-20 22:05:53 +00:00
First we setup our project URLconf to include URLs from `django-view-decorator`:
:::python
# project/urls.py (this is what we point the ROOT_URLCONF setting at)
from django.urls import path
from django_view_decorator import include_view_urls
urlpatterns = [
path("", include_view_urls()),
]
2023-06-21 20:30:28 +00:00
Then we can use the `view` decorator like so:
:::python
2023-06-20 22:05:53 +00:00
# foos/views.py
from django_view_decorator import view
@view(paths="/foo/", name="foo")
def foo(request: HttpRequest) -> HttpResponse:
2023-06-21 12:00:48 +00:00
return HttpResponse("foo")
2023-06-20 22:05:53 +00:00
We now have information about how the view is to be accessed right there next to the view itself.
Even class-based views are supported:
::python
@view(paths="/foo/", name="foo-list")
class FooList(ListView):
model = Foo
2023-06-21 20:30:28 +00:00
Now that is what I call Locality of Behaviour!
## More advanced usage
2023-06-20 22:05:53 +00:00
Multiple URLs can point at the same view, and you might have noticed that the argument is the plural `paths`. This is because we can pass a list of paths which point at the same view. Like so:
:::python
@view(
paths=[
"/foo/",
"/foo/<int:id>/",
],
name="foo",
namespace="foos",
)
def foo(request: HttpRequest, id: int | None = None) -> HttpResponse:
foos = Foo.objects.all()
if id:
context = {"foo": get_object_or_404(foos, id=id)}
template_name = "foo_detail.html"
else:
context = {"foos": foos}
template_name = "foo_list.html"
return render(
request,
template_name=template_name,
context=context
)
2023-06-21 20:30:28 +00:00
Looking at the view we can see that it is exposed on two paths, under the `foos` namespace, one which lists all `Foo` objects and one which given an integer gives us the detail for a single `Foo`.
2023-06-20 22:05:53 +00:00
If we want different names for each path we can simply apply the decorator multiple times:
:::python
@view(
paths="/foo/",
name="foo_list",
namespace="foos",
)
@view(
paths="/foo/<int:id>/",
name="foo_detail",
namespace="foos",
)
def foo(request: HttpRequest, id: int | None = None) -> HttpResponse:
...
## Behind the scenes
2023-06-21 11:27:50 +00:00
`django-view-decorator` works by having a registry in which all views and their URL information is stored. In fact the mechanism to do most of this work is the same as is used by `django.contrib.admin`. The `@view` decorator is quite similar to the well-known `@admin.register` decorator.
2023-06-21 11:27:50 +00:00
Here is a step-by-step for what is going on:
2023-06-21 11:27:50 +00:00
1. When Django starts and the app registry is ready, the `ready` method of `django_view_decorator.apps.ViewDecoratorAppConf` gets run.
2. The `ready` method calls `autodiscover_modules` from `django.utils.module_loading`. This imports `views.py` files from all apps in `INSTALLED_APPS`. The admin does the same thing, it just imports `admin.py` files.
3. By importing a `views.py` file we run all `@view()` invocations
4. In `view` decorator we gather information provided as arguments to the decorator and store this in a registry which is located at `django_view_decorator.apps.ViewRegistry`.
2023-06-21 11:57:10 +00:00
5. We can now use `ViewRegistry.urlpatterns()` to get the `urlpatterns` for all registrered views. The `include_view_urls` helper function exists to do exactly this.
2023-06-21 11:27:50 +00:00
There are of course some small "buts and ifs" sprinkled around, but by and large this is how the whole thing works.
2023-06-21 11:57:10 +00:00
One thing to note is that since we are only looking for `views.py` modules, views placed in other modules will not be picked up. To solve this the `include_view_urls` function takes a `extra_modules` arguments which works like so:
:::python
# project/urls.py
from django.urls import path
from django_view_decorator import include_view_urls
urlpatterns = [
path("", include_view_urls(
extra_modules=["foos.special_views"]
))
]
This would include views from `foos/special_views.py` in the registry.
## Namespaces and the power of factories
2023-06-21 20:30:28 +00:00
In the advanced example you might have noticed the `namespace="foos"`, which is going to quite tedious to repeat over and over again.
In Django URLconfs we can do namespacing by using the `include` function. So how do we do namespacing with this new pattern?
This is where the aptly named `namespaced_decorator_factory` comes into the picture. Let us look at an example:
:::python
# foo/views.py
from django_view_decorator import namespaced_decorator_factory
foo_view = namespaced_decorator_factory(
namespace="foos",
base_path="foos/",
)
@foo_view(paths="", name="list")
def foo_list(request: HttpRequest) -> HttpResponse:
2023-06-21 12:00:48 +00:00
return HttpResponse("foo list")
@foo_view(paths="<int:id>", name="detail")
def foo_detail(request: HttpRequest, id: int) -> HttpResponse:
2023-06-21 12:00:48 +00:00
return HttpResponse("foo detail")
By calling `namespaced_decorator_factory` we get a specialised decorator for our namespace and we can even provide it with a path which will be prepended to all URLs registered using it.
2023-06-21 20:30:28 +00:00
This opens up a the possibility of injecting URLs into a namespace from anywhere. For example:
:::python
# app_1/views.py
app_1_view = namespaced_decorator_factory(
namespace="app_1",
base_path="app_1/",
)
# app_2/views.py
from app_1.views import app_1_view
@app_1_view(
paths="my-custom-view/",
name="custom-view"
)
def custom_view(request: HttpRequest) -> HttpResponse:
2023-06-21 20:30:28 +00:00
return HttpResponse(
"I'm a view in the app_1 namespace."
)
Now we can treat `custom_view` as if it was a part of the `app_1` namespace. Ie. `reverse("app_1:custom-view")` would give us `app_1/my-custom-view/`. Neat!
2023-06-21 11:27:50 +00:00
### Namespaces for AppConfigs
2023-06-21 20:30:28 +00:00
While writing this blog post, and therefore trying to figure out how to implement this pattern in Django, I found that the application framework in Django is a great hook for creating namespaced decorators for apps. So `django-view-decorator` ships with its own `AppConfig` which can be used like so:
2023-06-21 11:27:50 +00:00
:::python
# foos/apps.py
from django_view_decorator import AppConfig
class FoosAppConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "foos"
namespace = "foos" # defaults to name from above
base_path = "foos"
view = FoosAppConfig.get_view_decorator()
# foos/views.py
from .apps import view
@view(paths="foo/", name="foo")
def foo(request: HttpRequest) -> HttpResponse:
return HttpResponse("foo")
I'm quite keen on this idea and I feel this might be an entry into introducing this pattern into Django.
Newcomers would learn that to hook up views to URLs they register their views into the app which the view belongs to - very much like how Flask does it with `@app`.
## The path to Django core
2023-06-21 20:30:28 +00:00
So, as I wrote initially, I have a mission to try to get this pattern into Django core. This is not going to be an easy feat, but I want to be upfront about my plan. Partly because I think it is going to enhance my chances of succeeding, but also so that the current project can move in the right direction.
2023-06-20 22:05:53 +00:00
To cite Carlton Gibson from the previously mentioned talk:
> Put it in a third party package. See if there is community interest. Then maybe it gets merged to the core if there is.
So this is what I'm doing.
1. Write the third party package - check!
2. Write a blog post - check!
3. Gather community interest - ongoing
4. Merge!
2023-06-21 20:30:28 +00:00
I want to emphasize that my goal is not the get the exact feature set of `django-view-decorator` into Django core. My goal is to use `django-view-decorator` as a, preferably stable, place to try out different approaches and evaluate which ideas are good and which are not.
One thing I have not touched upon in this blog post, is the different arguments which the decorator can take to replace decorators such as `@login_required` and `@permission_required`. That is simply because I'm not sure if the decorator should take on that responsibility. So everything is up in the air and my goal, besides ultimately getting this pattern into Django, is to figure out how this pattern benefits Django the most.
2023-06-21 11:57:10 +00:00
No matter whether my plan to get this pattern, in some form or another, into Django will succeed, I will continue to maintain `django-view-decorator` as an alternative to the current `views.py/urls.py` approach.
2023-06-20 22:05:53 +00:00
## What do you think?
2023-06-20 22:05:53 +00:00
So now I'm throwing the ball to the Django community!
2023-06-21 11:27:50 +00:00
What do you think? Should we just keep views and URLs separate or am I on to something? Does my solution have any major downsides which I have been blind to? Is there any missed opportunities in my implementation that would make it sing even more?
2023-06-20 22:05:53 +00:00
Come discuss on the Django forum in this dedicated thread:
2023-06-21 20:42:34 +00:00
<https://forum.djangoproject.com/t/bringing-locality-of-behaviour-to-django-views-and-urls/21765>