Build Your Own Python Web Framework From Scratch!

Sidharth SunuSidharth Sunu
5 min read

Welcome Back!

If you read my last blog, which was a kind of precursor to this one, it was about building your own HTTP file server from scratch. Now, as part 2, like I mentioned in that blog, as I built on that, I realized it was turning into a mini Python web framework. That got me thinking, why not build it since I'm already halfway there? And that's how this came to be. So, without wasting any more time, let's get into it!

In the previous parts of this blog series, we explored how to build a basic [[HTTP Server (Python)]] and a [[Basic HTTP File Server]]. Now, let’s take things up a notch β€” we're creating a mini web framework, inspired by how frameworks like Flask or Django work internally (but much simpler).

We split the logic into configurable and modular files:

πŸ—‚ File Structure

mini-web-framework/
β”‚
β”œβ”€β”€ main.py
β”œβ”€β”€ routes.py
β”œβ”€β”€ router.py
β”œβ”€β”€ utils.py
β”œβ”€β”€ config.py
└── templates/
    └── home.html

config.py:

import os

HOST = "192.168.0.124"
PORT = 9999
FILE_DIR = os.path.join(os.path.dirname(__file__), "files")
TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "templates")
  • HOST and PORT define where your server will run.

  • FILE_DIR points to the folder where user-uploaded or downloadable files live.

  • TEMPLATE_DIR points to where your HTML templates live.

Keeping this separate makes your code easier to maintain and portable.

utils.py:

import os
from config import TEMPLATE_DIR

def render_template(filename):
    filepath = os.path.join(TEMPLATE_DIR, filename)
    if not os.path.isfile(filepath):
        return "<h1>Template Not Found</h1>"

    with open(filepath, "r", encoding="utf-8") as f:
        return f.read()
  • First, we import TEMPLATE_DIR from config.

  • Next, we create a function, render_template(), where we build the file path using os.path.join() with the filename from the template directory.

  • Then, we check if the file actually exists:

    • If it's not there, we return "Template Not Found".

    • Otherwise, we return the contents using f.read().

router.py:

ROUTES = {}

def route(path):
    def decorator(func):
        ROUTES[path] = func
        return func
    return decorator

To understand this, we first need to learn about Python Decorators. You can find more information here: What are Python Decorators. Once we understand that, we can see that we create an empty dictionary called ROUTES, and then a decorator factory. This factory contains the actual decorator, which doesn't wrap or modify behavior like typical decorators. Instead, it registers the function. When we call the outer decorator, it returns the inner function, which can then be used to pass parameters to the inner decorator function. This essentially adds the parameter to ROUTES[path].

routes.py:

from router import route
from utils import render_template
import os
from config import FILE_DIR

@route("/")
def homepage(request):
    return 200, "text/html", "<h1>Hello World</h1>"

@route("/files/")
def list_files(request):
    try:
        files = os.listdir(FILE_DIR)
        html = "<h1>Files in server</h1><ul>"
        for f in files:
            html += f"<li><a href='/files/" + f + "'>" + f + "</a></li>"
        html += "</ul>"
        return 200, "text/html", html
    except FileNotFoundError:
        return 500, "text/plain", "Directory not found"

@route("/home")
def home_page(request):
    html = render_template("home.html")
    return 200, "text/html", html
  1. Here we import:

    • route from router.py, which is our custom decorator for mapping paths (URLs) to functions.

    • render_template from utils.py, which is a helper function used to load HTML files from the templates/ folder.

    • os, used to list the contents of the file directory.

    • FILE_DIR from config.py, which gives the absolute path to the folder that contains downloadable files.

  2. We use the @route() decorator, passing '/' as the route.

  3. This function is then called whenever the root URL (/) is accessed.

  4. It returns a status code 200, content type text/html, and a basic HTML message.

Route: /files/

@route("/files/")
def list_files(request):
    try:
        files = os.listdir(FILE_DIR)
        html = "<h1>Files in server</h1><ul>"
        for f in files:
            html += f"<li><a href='/files/" + f + "'>" + f + "</a></li>"
        html += "</ul>"
        return 200, "text/html", html
    except FileNotFoundError:
        return 500, "text/plain", "Directory not found"
  • This route handles listing all files present in the server’s files/ directory.

  • os.listdir(FILE_DIR) grabs all the filenames as a list.

  • We construct an HTML response dynamically using a loop, which creates a list of clickable links for each file.

  • These links point to URLs like /files/filename.txt, which (in your main logic) allow users to download them.

  • If the directory does not exist, it returns a 500 Internal Server Error with a plain-text message.

Route: /home

@route("/home")
def home_page(request):
    html = render_template("home.html")
    return 200, "text/html", html
  • This route returns a static HTML page from the templates/ directory.

  • The render_template("home.html") function reads the contents of templates/home.html and returns it as a string.

  • This is then returned with a status 200 and content type text/html, making it work just like a real webpage.

  • You can create multiple such pages and just call them using this function pattern.

main.py:

from http.server import HTTPServer, BaseHTTPRequestHandler
from router import ROUTES
import time
import routes  # Register all routes
from config import HOST, PORT

class SidhHTTP(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path in ROUTES:
            status, content_type, body = ROUTES[self.path](self)
            self.send_response(status)
            self.send_header("Content-type", content_type)
            self.end_headers()

            if isinstance(body, str):
                self.wfile.write(body.encode())
            else:
                self.wfile.write(body)
        else:
            self.send_error(404, "Page Not Found")

    def do_POST(self):
        self.send_response(200)
        self.send_header("Content-type", "application/json")
        self.end_headers()
        date = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
        self.wfile.write(bytes(f'{{"time": "{date}"}}', "utf-8"))

server = HTTPServer((HOST, PORT), SidhHTTP)
print("server running...")
server.serve_forever()
server.server_close()
print("server closed...")

The beginning is the same as other projects, but the difference appears when we reach the do_GET() function. Here, we first check if the path is in ROUTES. You might wonder how ROUTES is not empty. When we import routes at the top, the routes.py file is initialized, and the paths are added automatically. If the path is in ROUTES, we proceed as usual by sending the response, header, and ending the header. Then, we have an if statement that checks if the instance is binary or just HTML and handles it accordingly.

The POST part and the rest remain unchanged from the previous version(from the last HTTP server blog).

Templates:

A simple HTML page that could look like this:(for home.html)

<!DOCTYPE html>
<html>
<head>
    <title>Welcome</title>
</head>
<body>
    <h1>Welcome to My Python Mini Web Framework</h1>
    <p>This page is rendered from an HTML file in the templates folder.</p>
</body>
</html>

And just like that, we have completed our mini Python framework.

You should now have a good understanding of how to run this on your own. However, if you need the source code or detailed instructions, you can check out my GitHub with a helpful README: https://github.com/sidh-coder/mini-web-framework

4
Subscribe to my newsletter

Read articles from Sidharth Sunu directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Sidharth Sunu
Sidharth Sunu