MahmudCore - a simple Python web framework built from scratch for learning core web framework concepts
Project description
MahmudCore
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?
- Architecture Overview
- Installation
- Quick Start
- Features
- Running the Application
- Testing
- Project Structure
- Dependencies
- What I Learned Building This
- License
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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
91a382a81ebd983382f7fe17a279c877125374d352aeb8a72813f4c1031ee9d8
|
|
| MD5 |
731af7077d8e6300197372850fc2eb29
|
|
| BLAKE2b-256 |
e081f6492fcc6eb2678a401b3a4af524fdacd17db591100f46fa25c2b18bbdc1
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7fce36a6937621491ee585c8105b8c81f054c88256dae6ea7ee0d306cde72d01
|
|
| MD5 |
5363e58fd247104643f12d538c74cdf0
|
|
| BLAKE2b-256 |
7c31544daa36ccc4975386ba855ff4a17b60e5e876879dbbdfcd7cb1ebc83973
|