Moving from Django Microframework to Django

Moving from Django Microframework to Django

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 creating wsgi.py, settings.py, urls.py, and manage.py in the folder config. 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 and MIDDLEWARE do.
  • When we run python manage.py startapp appname, the files apps.py, models.py, admin.py, tests.py and views.py are created in the folder appname.