Skip to main content

MahmudCore - a simple Python web framework built from scratch for learning core web framework concepts

Project description

MahmudCore

Purpose PyPI Python Version License Status

MahmudCore is a Python web framework built from scratch for exploring and understanding the core concepts that power every modern web framework. It is a WSGI-compliant framework that can be used with any WSGI application server such as Gunicorn or Waitress.

Built by Md. Mahmudul Hasan - layer by layer, from raw WSGI to a fully-featured framework.


Table of Contents


Why MahmudCore?

Most developers use Flask or Django without understanding what happens underneath. MahmudCore was built to answer the question: what is a web framework actually doing?

Every feature in MahmudCore was built from first principles:

  • Starting from the raw WSGI spec (environ, start_response)
  • Adding routing, then dynamic routing, then class-based handlers
  • Integrating Jinja2 for templates, WhiteNoise for static files
  • Building a middleware pipeline from scratch
  • Packaging and publishing to PyPI

If you want to understand how Flask, Django, or FastAPI work under the hood - reading MahmudCore is a great place to start.


Architecture Overview

Incoming HTTP Request
        │
        ▼
   __call__  (WSGI entry point)
        │
        ├─── /static/* ──► WhiteNoise ──► serve file from disk
        │
        └─── everything else
                │
                ▼
        Middleware pipeline
        (LoggingMiddleware → AuthMiddleware → ...)
                │
                ▼
        handle_request
                │
                ├─── find_handler (parse URL pattern)
                │
                ├─── class-based handler?
                │       └─── getattr(Handler(), 'get' / 'post' / ...)
                │
                ├─── function-based handler?
                │       └─── check allowed_methods list
                │
                └─── default_response (404)
                        │
                        ▼
                Response object
                (json / html / text / body)
                        │
                        ▼
              response(environ, start_response)

Installation

pip install MahmudCore

Install with development tools:

pip install MahmudCore[dev]

Quick Start

Create app.py:

from mahmudcore.api import API

app = API()

@app.route("/")
def home(request, response):
    response.text = "Hello from MahmudCore!"

@app.route("/hello/{name}")
def greet(request, response, name):
    response.text = f"Hello, {name}!"

Run with Gunicorn:

gunicorn app:app

Or Waitress (Windows-friendly):

waitress-serve --listen=127.0.0.1:8080 app:app

Visit http://localhost:8000 in your browser.


Features

Routing

MahmudCore supports two routing styles - Flask-style decorators and Django-style explicit registration.

Decorator style:

@app.route("/home")
def home(request, response):
    response.text = "Hello from HOME"

Django style:

def home(request, response):
    response.text = "Hello from HOME"

app.add_route("/home", home)

Both styles use the same underlying self.routes dictionary and share the same duplicate detection:

@app.route("/home")
def home_v1(request, response): ...

@app.route("/home")          # ← AssertionError: Route '/home' already exists.
def home_v2(request, response): ...

Dynamic URL parameters using the parse library:

@app.route("/hello/{name}")
def greet(request, response, name):
    response.text = f"Hello, {name}!"

@app.route("/books/{id:d}")       # :d = digits only
def book_detail(request, response, id):
    response.text = f"Book #{id}"

@app.route("/users/{username:w}/profile")   # :w = word characters
def profile(request, response, username):
    response.text = f"{username}'s profile"

Class-Based Handlers

Organise multiple HTTP methods for the same URL into a single class - similar to Django's class-based views:

@app.route("/books")
class BooksResource:
    def get(self, req, resp):
        resp.json = {"books": ["Book 1", "Book 2"]}

    def post(self, req, resp):
        resp.json = {"message": "Book created"}

@app.route("/users/{id:d}")
class UserResource:
    def get(self, req, resp, id):
        resp.text = f"Get user {id}"

    def put(self, req, resp, id):
        resp.text = f"Update user {id}"

    def delete(self, req, resp, id):
        resp.status_code = 204

Calling an HTTP method not defined on the class raises AttributeError("Method not allowed").


HTTP Method Control

Restrict which HTTP methods a function-based handler accepts:

@app.route("/api/users", allowed_methods=["get", "post"])
def users_api(request, response):
    if request.method == "GET":
        response.json = {"users": []}
    elif request.method == "POST":
        response.json = {"message": "User created"}

@app.route("/api/report", allowed_methods=["get"])
def report(request, response):
    response.text = "Quarterly report"

If a disallowed method is used, AttributeError is raised, which can be caught by your exception handler.


Template Rendering

MahmudCore integrates Jinja2 for dynamic HTML generation:

app = API(templates_dir="templates")

@app.route("/page")
def page(request, response):
    response.html = app.template("index.html", context={
        "title": "My Page",
        "username": "Mahmud",
        "items": ["Python", "WSGI", "Frameworks"],
    })

templates/index.html:

<html>
  <head><title>{{ title }}</title></head>
  <body>
    <h1>Welcome, {{ username }}</h1>
    <ul>
      {% for item in items %}
        <li>{{ item }}</li>
      {% endfor %}
    </ul>
  </body>
</html>

Static Files

MahmudCore uses WhiteNoise to serve static assets. Place your files inside the static/ folder and reference them with the /static/ URL prefix:

app = API(static_dir="static")
static/
├── main.css
├── app.js
└── logo.png

In HTML:

<link rel="stylesheet" href="static/main.css">
<script src="static/app.js"></script>

The framework automatically strips the /static prefix and passes the request to WhiteNoise for file serving.


Middleware

Create reusable components that run before and after every request. Middleware is the right place for cross-cutting concerns like logging, authentication, timing, and CORS.

Creating middleware:

from mahmudcore.middleware import Middleware

class LoggingMiddleware(Middleware):
    def process_request(self, req):
        print(f"→ {req.method} {req.url}")

    def process_response(self, req, resp):
        print(f"← {resp.status_code}")


class RequestTimingMiddleware(Middleware):
    def process_request(self, req):
        import time
        req.start_time = time.time()

    def process_response(self, req, resp):
        duration = time.time() - req.start_time
        resp.headers["X-Response-Time"] = f"{duration:.4f}s"

Registering middleware:

app.add_middleware(LoggingMiddleware)
app.add_middleware(RequestTimingMiddleware)

Middleware is chained as nested wrappers. The last registered middleware runs first on incoming requests. Each middleware calls process_request before passing the request down, and process_response on the way back up.

The pipeline looks like this:

Request → RequestTimingMiddleware → LoggingMiddleware → API
Response ← RequestTimingMiddleware ← LoggingMiddleware ← API

Exception Handling

Register a custom exception handler to catch any unhandled error that occurs inside a route handler:

def on_error(request, response, exception):
    response.status_code = 500
    response.json = {
        "error": type(exception).__name__,
        "message": str(exception),
    }

app.add_exception_handler(on_error)

@app.route("/risky")
def risky_handler(request, response):
    raise ValueError("Something went wrong")
# → Returns JSON error instead of crashing

Custom exception classes work too:

class UnauthorizedException(Exception):
    pass

def on_error(request, response, exc):
    if isinstance(exc, UnauthorizedException):
        response.status_code = 401
        response.text = "Unauthorized"
    else:
        response.status_code = 500
        response.text = "Internal Server Error"

app.add_exception_handler(on_error)

Custom Response

The Response object provides clean, type-specific helpers that handle encoding and content-type automatically:

# JSON response - automatic serialization + application/json header
@app.route("/api/data")
def data(req, resp):
    resp.json = {"name": "MahmudCore", "version": "0.0.1"}


# HTML response - automatic encoding + text/html header
@app.route("/page")
def page(req, resp):
    resp.html = app.template("index.html", context={"title": "Home"})


# Plain text response - automatic text/plain header
@app.route("/ping")
def ping(req, resp):
    resp.text = "pong"


# Raw bytes - manual content type required
@app.route("/raw")
def raw(req, resp):
    resp.body = b"raw bytes"
    resp.content_type = "application/octet-stream"


# Set status code
@app.route("/created")
def created(req, resp):
    resp.json = {"id": 1}
    resp.status_code = 201


# Set custom headers
@app.route("/cors")
def cors(req, resp):
    resp.text = "OK"
    resp.headers["Access-Control-Allow-Origin"] = "*"

Running the Application

Gunicorn (recommended for Linux/macOS):

gunicorn app:app
gunicorn app:app --bind 0.0.0.0:8080 --workers 4

Waitress (recommended for Windows):

waitress-serve --listen=127.0.0.1:8080 app:app

Testing

MahmudCore includes a built-in test client that sends requests directly to the WSGI app - no running server needed:

# conftest.py
import pytest
from mahmudcore.api import API

@pytest.fixture
def api():
    return API()

@pytest.fixture
def client(api):
    return api.test_session()
# test_app.py
def test_home_returns_200(api, client):
    @api.route("/home")
    def home(req, resp):
        resp.text = "Hello"

    response = client.get("http://testserver/home")
    assert response.status_code == 200
    assert response.text == "Hello"


def test_404_for_unknown_route(client):
    response = client.get("http://testserver/nonexistent")
    assert response.status_code == 404


def test_duplicate_route_raises(api):
    @api.route("/test")
    def handler(req, resp): pass

    import pytest
    with pytest.raises(AssertionError):
        @api.route("/test")
        def handler2(req, resp): pass


def test_method_not_allowed(api, client):
    @api.route("/read-only", allowed_methods=["get"])
    def handler(req, resp):
        resp.text = "OK"

    import pytest
    with pytest.raises(AttributeError):
        client.post("http://testserver/read-only")

Run tests:

pytest
pytest --cov=mahmudcore        # with coverage
pytest --cov=mahmudcore --cov-report=html   # HTML report

Project Structure

mahmudcore/
├── mahmudcore/
│   ├── __init__.py
│   ├── api.py          # Core API class - routing, WSGI entry point
│   ├── middleware.py   # Base Middleware class and pipeline
│   └── response.py     # Custom Response class
├── templates/          # Jinja2 HTML templates
├── static/             # Static assets (CSS, JS, images)
├── app.py              # Your application
├── test_app.py         # Tests
├── conftest.py         # Pytest fixtures
├── setup.py            # Package configuration
└── README.md

Dependencies

Package Purpose
webob WSGI Request/Response objects
jinja2 HTML template engine
parse URL parameter extraction
whitenoise Static file serving
requests HTTP client (used in test session)
requests-wsgi-adapter Connects requests to WSGI for testing

Install all at once:

pip install webob jinja2 parse whitenoise requests requests-wsgi-adapter

What I Learned Building This

Building MahmudCore from scratch across open-secrete labs😉 taught me how every Python web framework actually works:

WSGI is just a contract: your app must be a callable that accepts (environ, start_response) and returns an iterable of bytes. Everything else - routing, templates, middleware - is built on top of that single rule.

__call__ on a class makes it behave like a function. This is how the API class, every middleware, and WhiteNoise all work together as WSGI callables.

Middleware is just wrapping. Reverseware(app), WhiteNoise(app), AuthMiddleware(app) - they all follow the same pattern: receive a request, optionally do something, call the inner app, optionally do something to the response.

inspect.isclass(handler) is how function-based and class-based handlers are distinguished. getattr(Handler(), request.method.lower(), None) is how class-based method routing works.

parse library does the heavy lifting for dynamic URL patterns like /users/{id:d}. Flask uses a similar approach with Werkzeug's routing.

WhiteNoise as WSGI middleware wraps wsgi_app and intercepts static file requests before they reach the routing logic.


License


Built with curiosity by Md. Mahmudul Hasan

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

mahmudcore-0.0.2.tar.gz (10.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

mahmudcore-0.0.2-py3-none-any.whl (9.3 kB view details)

Uploaded Python 3

File details

Details for the file mahmudcore-0.0.2.tar.gz.

File metadata

  • Download URL: mahmudcore-0.0.2.tar.gz
  • Upload date:
  • Size: 10.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.12

File hashes

Hashes for mahmudcore-0.0.2.tar.gz
Algorithm Hash digest
SHA256 91a382a81ebd983382f7fe17a279c877125374d352aeb8a72813f4c1031ee9d8
MD5 731af7077d8e6300197372850fc2eb29
BLAKE2b-256 e081f6492fcc6eb2678a401b3a4af524fdacd17db591100f46fa25c2b18bbdc1

See more details on using hashes here.

File details

Details for the file mahmudcore-0.0.2-py3-none-any.whl.

File metadata

  • Download URL: mahmudcore-0.0.2-py3-none-any.whl
  • Upload date:
  • Size: 9.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.12

File hashes

Hashes for mahmudcore-0.0.2-py3-none-any.whl
Algorithm Hash digest
SHA256 7fce36a6937621491ee585c8105b8c81f054c88256dae6ea7ee0d306cde72d01
MD5 5363e58fd247104643f12d538c74cdf0
BLAKE2b-256 7c31544daa36ccc4975386ba855ff4a17b60e5e876879dbbdfcd7cb1ebc83973

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page