The Python ecosystem has a few Web application frameworks, most prominent being Flask and Django. They both enjoy their popularity in the community; The general consensus is, Hey, use Flask for the small projects and Django for the large projects. It is true though that with Flask, after the project reaches certain size and complexity, it becomes harder and harder to manage and compose together all 3rd party extensions (which may or may not work) to achieve the desired functionality. The Django ecosystem seems to be more stable with all built in functionality plus powerful semi-official extensions like Django Rest Framework or Django Channels. Well, you might say, that may be true, but with Flask I can hack a simple web app in one Python file with a few lines of code. In contrast, with Django, in order to get started, I need to run some mastodon tools like django-admin, and it is not very clear what they actually do. Too much magic, you may say. I hear you. Most of the documentation, books and youtube videos indeed emphasize power of Django built-in functionality and rarely mention the biggest Django design strength which is, in my opinion, its modularity and composability.
So let's see how we can replicate a simple app, which is typically showcase for the micro-frameworks like Flask. In the second part we will extend our simple app so it does something useful.
All in one Python file.
The project should work on Mac, Linux or Windows. We are going to point out if on certain OS something special has to be done (I am looking at you, Windows).
Let's create the directory for our project.
mkdir microdjango
cd microdjango
Create a virtual environment and activate it. You know the drill:
python3 -m venv .venv
source .venv/bin/activate
on Windows:
python -m venv .venv
.venv/scripts/activate
Install Django in your virtual environment:
pip install django
Now, things become interesting. We are not going to use the django-admin
, as usually with Django.
We'll just create a Python file:
touch mandel.py
It is going to become clear later why I have chosen such a name. Having said that, name it as you wish. Ok, now we need to do the following:
- create a Django view which returns, say, a string
- tell Django on what url this view has to respond.
- somehow kick off the project to make it running.
Creating the view is quite straightforward, in the
mandel.py
enter the following:
from django.http import HttpResponse
def index(request):
return HttpResponse('oh, hai')
To specify the url on which this view should respond we do:
# new
from django.urls import path
urlpatterns = [path('', index)]
we essentially say that our index view will respond on the root address of the app. Ok, we have a view and specify its url, how do we start actual app. Make the following change:
# new
import sys
from django.core.management import execute_from_command_line
if __name__=='__main__':
execute_from_command_line(sys.argv)
This short snippet of code does what the manage.py
does in the app generated by the django-admin
. The command line parameters (sys.argv
) are passed in, so Django will be able to respond to commands like runserver
and others
If we try to run the file now:
python mandel.py runserver
It will complain that our application is not configured yet. Let's create a minimal configuration, insert the following:
#...
#new
from django.conf import settings
settings.configure(ROOT_URLCONF=__name__, ALLOWED_HOSTS=['*'])
if __name__=='__main__':
execute_from_command_line(sys.argv)
the ROOT_URLCONF
is probably the most important setting. It specifies the module where to find the root url map. as we created our map urlpatterns
in the current file, we say, look up in the current module.
The ALLOWED_HOSTS
is Django security measure which limits the domain names that the Django can serve. For this simple exercise we say, everything is allowed.
Another setting you may want to consider, even for the simple apps, is DEBUG=True
. That will dump some useful debugging info if something goes wrong.
ok, start the app now:
python mandel.py runserver
navigate with the browser to http://localhost:8000/ and see how our app responds.
For convenience, here is the complete mandel.py
file:
import sys
from django.conf import settings
from django.core.management import execute_from_command_line
from django http import HttpResponse
from django.urls import path
def index(request):
return HttpResponse('oh, hai')
urlpatterns = [path('', index)]
settings.configure(ROOT_URLCONF=__name__, ALLOWED_HOSTS=['*'])
if __name__=='__main__':
execute_from_command_line(sys.argv)
If you ask me, it is fairly comparable to the Minimal Flask app and does about that much. But we are just starting...
Well, the runserver
feature is not designed to be used for production, but rather as a convenience feature during the development. For production you need something better, gunicorn is a popular option.
Lets install it:
pip install gunicorn
Note the the gunicorn does not work on Windows. If you are running Windows, consider, for example waitress instead. Once you have one of these installed, we need to ask Django to turn our app into a "qsgi" application, so it speaks with the gunicorn/waitress the language they understand. Add this:
# as before ...
# new
from django.core.wsgi import get_wsgi_application
app = get_wsgi_application()
if __name__ == "__main__":
execute_from_command_line(sys.argv)
That should prepare our app for gunicorn. Try it:
gunicorn mandel:app
The app should respond on the same address as before.
Well, the 'hello world' app is nice and all, but it is kind of boring. Can our app serve something more interesting?
We will replace our text response with the image response. There are many ways to generate images. We will use numpy to generate a matrix representing the image and pillow to convert that matrix to the actual image. Let's install these two:
pip install numpy
pip install pillow
Create the helper function which emits the pixel array. For now, it is going to be a plain red image.
# new
import numpy as np
def mandelbrot(w, h, fro, to):
x = np.linspace(fro.real, to.real, num=w).reshape((1, w))
y = np.linspace(fro.imag, to.imag, num=h).reshape((h, 1))
c = np.tile(x, (h, 1)) + 1j * np.tile(y, (1, w))
z = np.zeros((h, w), dtype=complex)
valid = np.full((h, w), True, dtype=bool)
for i in range(128):
z[valid] = z[valid] * z[valid] + c[valid]
valid[np.abs(z) > 2] = False
arr = np.flipud(1 - valid)
return np.dstack([arr, arr, arr])
def generate(width, height):
arr = np.zeros((height, width, 3))
arr[:,:, 0] = 1
return arr
If you are not very familiar with numpy, this generates a 3-dimensional matrix of the dimension height x width x 3 (because we want a RGB image, therefore 3 for red, green, blue). it is initialized with zeros, and later we set the red "slice" of it into ones. One note that numpy requires the height (rows) passed before width (columns) Now change you view so it returns an image instead of text:
# new
from io import BytesIO
from PIL import Image
# changed
def index(request):
arr = generate(640, 480)
img = Image.fromarray(np.uint8(arr * 255), "RGB")
bytes = BytesIO()
img.save(bytes, "PNG")
bytes.seek(0)
return HttpResponse(bytes, content_type="image/png")
The generated array is made of float numbers between 0 and 1, Pillow expects the array of bytes between 0 and 255, therefore we convert it to the required form. After that, we write the image into in-memory buffer and return that buffer in the HttpResponse as a png image.
If we restart our app:
python mandel.py runserver
and navigate again to the http://localhost:8000, we should see our app now returns a plain red image.
Plain images are boring, let' do something more interesting. Mandelbrot set is a peculiar object generated by a relatively simple mathematical procedure. The details of it are not important for our discussion, so you just can copy-paste the code as is:
def mandelbrot(w, h, fro, to):
x = np.linspace(fro.real, to.real, num=w).reshape((1, w))
y = np.linspace(fro.imag, to.imag, num=h).reshape((h, 1))
c = np.tile(x, (h, 1)) + 1j * np.tile(y, (1, w))
z = np.zeros((h, w), dtype=complex)
valid = np.full((h, w), True, dtype=bool)
for i in range(128):
z[valid] = z[valid] * z[valid] + c[valid]
valid[np.abs(z) > 2] = False
arr = np.flipud(1 - valid)
return np.dstack([arr, arr, arr])
If you are mathematically inclined and familiar with numpy, it should not be hard to figure out what is going on here. But, as mentioned, it is totally unnecessary for our purpose.
replace the generate
function with the following:
# replaced
def generate(width, height):
return mandelbrot(width, height, -2.25 - 1.25j, 0.75 + 1.25j)
restart your app and try to see what it generates now. I love math!
For now, the dimensions of the generated image are hardcoded. Let's change that so they can be passed as a part of url.
# changed
def index(request, width=800, height=600):
width = min(width, 1200)
width = max(width, 100)
height = min(height, 1024)
height = max(height, 50)
arr = generate(width, height)
# as before ...
Now our view takes two optional parameters, width and height of the generated image. We also sanitize the parameters so the image is not too big and not too small. We need to provide the url which is aware of the new parameters. Change the url map to the:
urlpatterns = [
path("", index),
path("generate/<int:width>/<int:height>", index)
]
The default route remains, but there is a new one which allows passing width and height of the image. Try it on:
http://localhost:8000/generate/1200/1024
The large the dimensions are, the longer it takes to compute the image. But, thanks to the efficiency of numpy, it should not be too bad.
As we just have witnessed, Django scales down pretty easily and it is very possible to use it for the simple web apps instead of, say, Flask. Having said that, there is nothing wrong with Flask. It is quite elegant and minimalistic framework, so it is good to have both in your arsenal
github link: https://github.com/lechgu/microdjango
live demo: https://mandel.aiki.dev/