At DjangoConUS 2019, Carlton Gibson gave a talk called Using Django as a Micro-Framework, where he demonstrated a single file implementation of "Hello, World!" in Django.
At the sprints during DjangoConUS 2023, Paolo Melchiorre showed off an even more minimal version that sparked some great discussion. Specifically, the usage of lambda in this initial version of Paolo's effort made me realize that using the original function based version, you could build from that single file to what an early Django project typically looks like. After a great discussion over lunch with Eric Matthes about this, I decided to write this post to show just that: going from a single file to a project where a user has run startproject
and startapp
.
I hope this demystifies some of the 'magic' behind Django and we can learn some things in the process. Note that we are using Django 4.2.X for this example.
Getting Started
I forked Will Vincent's repo for django-microframework and have made a few modifications to get us started. First, I renamed hello_django1.py
to hello_django.py
because we only need one file, and this one was closer to what I wanted to build from. In my fork, the main
branch will have the final version of this code, but I've also created branches that start at the beginning and mark important steps in our journey. Here is the first branch.
Writing Blog Posts
We are going to be writing a blog, so let's start with some 'blog posts' in hello_django.py
:
from django.conf import settings
from django.core.handlers.wsgi import WSGIHandler
from django.core.management import execute_from_command_line
from django.http import HttpResponse
from django.urls import path
settings.configure(
ROOT_URLCONF=__name__,
DEBUG=True,
)
def hello_world(request):
return HttpResponse("Hello, Django!")
def second_post(request):
return HttpResponse("This is my second blog post. I'm still figuring this whole thing out.")
def third_post(request):
return HttpResponse(
"This is my third blog post. I'm feeling more comfortable with this whole thing, and I'm going to try to write a bit more. "
)
def fourth_post(request):
return HttpResponse(
"Hot dogs are not a sandwich. In this blog post I will first define the meaning of sandwich, then I will ???? which will conclusively prove that hot dogs are indeed not sandwiches."
)
urlpatterns = [
path("hello-world", hello_world),
path("second-post", second_post),
path("third-post", third_post),
path("fourth-post", fourth_post),
]
application = WSGIHandler()
if __name__ == "__main__":
execute_from_command_line()
Here is the branch for that code.
Now that we have those, hello_django.py
is getting a bit long, and we need to focus on our writing. What can we do to simplify our code so we can do that? Let's move some of the code from hello_django.py
into a few other files.
manage.py
First, we can move the last couple of lines (and the associated import) from hello_django.py
to manage.py
:
from django.core.management import execute_from_command_line
if __name__ == "__main__":
execute_from_command_line()
But that yields:
django.core.exceptions.ImproperlyConfigured: Requested setting DEBUG, but settings are not configured. You must either define the environment variable DJANGO_SETTINGS_MODULE or call settings.configure() before accessing settings.
So we define DJANGO_SETTINGS_MODULE
and now we have a working manage.py
:
from django.core.management import execute_from_command_line
import os
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hello_django.settings")
execute_from_command_line()
I'd like to pause here to highlight that a lot of magic misdirection happens when we run python manage.py runserver
. In order to understand what is happening, though, we only need to go to the Django source code for execute_from_command_line()
. This function handles whatever command you call after python manage.py
, such as runserver
, makemigrations
, and migrate
. Walking through these is a post (maybe a series) in and of itself, but at least now we know where to find what happens when we call a command using python manage.py
.
Back to our Blog
We still have a few more lines that we can move out of hello_django.py
to really focus on our posts. Let's move everything that isn't the posts themselves into their own files starting from the top, moving settings.configure()
into settings.py
:
from django.conf import settings
settings.configure(
ROOT_URLCONF="urls",
DEBUG=True,
)
Now, we can match the change we made to the location of our URLs in settings.py
by creating urls.py
:
from django.urls import path
from hello_django import hello_world, second_post, third_post, fourth_post
urlpatterns = [
path("hello-world", hello_world),
path("second-post", second_post),
path("third-post", third_post),
path("fourth-post", fourth_post),
]
Finally, let's create wsgi.py
:
from django.core.handlers.wsgi import WSGIHandler
application = WSGIHandler()
Awesome. Now hello_django.py
contains only the blog posts.
But we've done something else here, too.
Let's take one final step to see what else we've accomplished. We can make a folder called config
(the name isn't important, so you can call this what you want) and move our new files there: wsgi.py
, settings.py
, and urls.py
.
Now we have to match our paths so we update manage.py
:
from django.core.management import execute_from_command_line
import os
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.settings")
execute_from_command_line()
and settings.py
:
from django.conf import settings
settings.configure(
ROOT_URLCONF="config.urls",
DEBUG=True,
)
Here is a link to the third branch with the current state of our code.
We've just done (almost) everything that happens when you create a new django project and execute:
django-admin startproject config .
Two notable exceptions:
First, we have not created asgi.py
, which gets created with new projects as of Django 3.0. This isn't an asynchronous project, so we can leave it out.
Second, you may notice our manage.py
has less code than the version that Django creates and that we set our DJANGO_SETTINGS_MODULE
environment variable in manage.py
rather than in wsgi.py
. I'm choosing to leave these as is for this exercise to simplify moving from the original code to our destination. I wanted to mention it in case it confuses any readers looking at those files in their own projects, but I don't want to slow us down by diving into every difference between this project and a project where you've run startproject
.
After startproject
You may notice that hello_django.py
now looks an awful lot like views.py
that you're likely familiar with from any other Django project, so let's rename it to reflect that.
Since we're going to want to write longer blog posts than we currently have, it doesn't make much sense to hard code our blog entries. Let's store those in a database. For that, we'll need to set up models.py
and add DATABASES
to settings.py
:
from pathlib import Path
from django.conf import settings
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
settings.configure(
ROOT_URLCONF="config.urls",
DEBUG=True,
DATABASES={
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
},
)
For the sake of simplicity, here is a minimal models.py
:
from django.db import models
class Post(models.Model):
body = models.TextField()
We've got our model set up, but how can we move our posts into our database? Well, to start with, we can set up our Django admin panel for this project. This requires several changes. First, I added only the required settings here by adding django.contrib.admin
to INSTALLED_APPS
. then I added each app/middleware entry to address an error message until python manage.py runserver
successfully ran without errors.
After those additions, here is settings.py
:
from pathlib import Path
from django.conf import settings
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
settings.configure(
ROOT_URLCONF="config.urls",
DEBUG=True,
DATABASES={
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
},
INSTALLED_APPS=[
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.messages",
"django.contrib.sessions",
],
MIDDLEWARE=[
"django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
],
SECRET_KEY="django-insecure-4@r)20xhni#*+xcl*8u0t9b)85djrm+3(^0(7pzbb#7+l337*r",
TEMPLATES=[
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
],
)
Installed Apps and Middleware
Before we get to the admin, I want to focus on INSTALLED_APPS
and MIDDLEWARE
. Similar to running commands with manage.py
, these hide a lot of complexity. The upside is that this is where a lot of the 'batteries' are stored that people refer to when saying Django is a batteries included framework. To elaborate, let's start with django.contrib.admin
in INSTALLED_APPS
. While this is a string in our file, it's actually a reference to code in Django itself. Similarly, you can find the other INSTALLED_APPS
by navigating the code there.
Similarly, the strings in the MIDDLEWARE
list all refer to classes in the Django codebase that you can navigate to by seeing each element in the string (separated by a period) as a folder in the Django codebase (until the last which is the PascalCase class name). Example: django.contrib.sessions.middleware.SessionMiddleware.
Admin
We are now ready for admin.py
:
from django.contrib import admin
from .models import Post
admin.site.register(Post)
startapp
We've hit a bit of a stopping point because we won't be able to see our new model in the admin until we've created and run the migration. In order to do that, we need an installed application for python manage.py makemigrations
to pick up the new model. We could run python manage.py startapp
to create these files, but we're already most of the way there, so let's create the folder ourselves (call it blog
) and move models.py
, admin.py
, and views.py
into it.
We need to update the views
import, and add the admin
route in urls.py
:
from django.contrib import admin
from django.urls import path
from blog.views import hello_world, second_post, third_post, fourth_post
urlpatterns = [
path("hello-world", hello_world),
path("second-post", second_post),
path("third-post", third_post),
path("fourth-post", fourth_post),
path("admin/", admin.site.urls),
]
Make and run migrations:
python manage.py makemigrations
python manage.py migrate
We have a functioning admin!
But, in order to log in, we'll have to create a super user:
python manage.py createsuperuser
Ok, now we can move our blog posts into the database. Go to your local admin and log in. Click 'Add' down by 'Posts', and add some text to the field labeled 'Body'. Click 'Save and Add Another' and repeat at least four times.
After we've added all four posts, we can update views.py
:
from django.http import HttpResponse
from blog.models import Post
def hello_world(request):
return HttpResponse(Post.objects.all().get(id=1).body)
def second_post(request):
return HttpResponse(Post.objects.all().get(id=2).body)
def third_post(request):
return HttpResponse(Post.objects.all().get(id=3).body)
def fourth_post(request):
return HttpResponse(Post.objects.all().get(id=4).body)
Run python manage.py runserver
and navigate to one of the post URLs (here's our first one), and you can see that we're now getting the text from our database. Hooray!
We're almost done, but since we're good software developers, we should add a test for this project in tests.py
:
from django.test import TestCase
from .models import Post
class BlogTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.post = Post.objects.create(
body="post text",
)
def test_post_model(self):
self.assertEqual(self.post.body, "post text")
Run the test.
python manage.py test blog.tests
Finally, for the sake of completion, and to get rid of that warning you've probably been seeing if you're coding along, we'll create blog/apps.py
:
from django.apps import AppConfig
class BlogConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "blog"
And that's it! Our project now matches a project that has run python manage.py startapp blog
.
What have we learned?
- When we run
django-admin startproject config .
, we are creatingwsgi.py
,settings.py
,urls.py
, andmanage.py
in the folderconfig
. Now we know what these do and contain. - If we want to know more about what a
manage.py
command does, we know we can look into the Django source code. - We can also visit the source code to better understand what any section of
INSTALLED_APPS
andMIDDLEWARE
do. - When we run
python manage.py startapp appname
, the filesapps.py
,models.py
,admin.py
,tests.py
andviews.py
are created in the folderappname
.