Python Module 16 – Practical Python: text, CLIs, web, automation and security
Serving HTTP: http.server and WSGI basics
Serve a folder and a JSON endpoint with Python's http.server, write a WSGI app that a test can call directly, and see what real servers add.
What you will learn
- Run python -m http.server for a folder, bound to 127.0.0.1 only
- Write a JSON endpoint with BaseHTTPRequestHandler
- Write a WSGI application and test it by calling it with an environ dict
- Check a WSGI app with wsgiref.validate, and limit the size of request bodies
- Explain what middleware, production servers and frameworks add to a WSGI app
Before you start
What you’ll learn · Before you start
What you will learn
- Run python -m http.server for a folder, bound to 127.0.0.1 only
- Write a JSON endpoint with BaseHTTPRequestHandler
- Write a WSGI application and test it by calling it with an environ dict
- Check a WSGI app with wsgiref.validate, and limit the size of request bodies
- Explain what middleware, production servers and frameworks add to a WSGI app
Before you start
On this page
python -m http.server turns the folder you are in into a website on port 8000 with one command, and unless you say
otherwise, every device on the same Wi-Fi can open it. Python’s built-in servers are made for development: the
documentation says plainly that http.server is not recommended for production.
This lesson serves a folder and then a JSON endpoint with http.server, and writes the same endpoint as a
A Python web application written to PEP 3333: a callable that takes the request as a dict, environ, and a start_response function, and returns the response body as an iterable of bytes.:
a function that takes the request as a dict and returns the body as bytes. WSGI, defined in PEP 3333, is the
interface between Python web servers and applications, so an app written to it runs under the standard library’s
wsgiref now and under a production server later, and a test can call it without opening a port.
http.server shares a folder with every device that can reach it
The module answers a request for a path with the file at that path, and a request for a folder with its index.html
or, when it has none, a listing of it. Step through who can reach it, depending on the address it binds to:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- One command, every interface. python -m http.server serves the folder it runs in, on port 8000. Without --bind it listens on every network interface of the laptop, the Wi-Fi included.
- Your browser. The browser on the laptop opens http://127.0.0.1:8000/ and gets a listing of the folder, as you meant.
- Someone else's phone. A phone on the same Wi-Fi opens the laptop's address on port 8000 and gets the same listing, with every file in the folder and in the folders below it.
- Bound to 127.0.0.1. Run it again as python -m http.server --bind 127.0.0.1. Now it listens only on the loopback interface, which only programs on the laptop itself can reach.
- The phone is refused. Nothing listens on the laptop's Wi-Fi address at port 8000 any more, so the phone's connection is refused before any file could be read.
- Still yours. The browser on the laptop still gets the listing, so local testing works exactly as before.
By default, the documentation says, the server binds itself to all interfaces, which is why the phone got the
listing. --bind 127.0.0.1 keeps it to the laptop and --directory serves another folder than the current one. The
same server inside a program, bound to 127.0.0.1 and serving a small site folder:
"""What python -m http.server does, inside a program: serve one folder on 127.0.0.1 and fetch from it."""
import functools
import re
import threading
import urllib.error
import urllib.request
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
SITE = Path(__file__).parent / "site"
class QuietHandler(SimpleHTTPRequestHandler):
def log_message(self, *args): # the real log line starts with the time, which this page never shows
pass
handler = functools.partial(QuietHandler, directory=str(SITE))
server = ThreadingHTTPServer(("127.0.0.1", 0), handler) # 127.0.0.1: only this computer can connect
threading.Thread(target=server.serve_forever, daemon=True).start()
base = f"http://127.0.0.1:{server.server_port}"
print("serving", SITE.name, "on", server.server_address[0])
for path in ["/", "/notes.txt", "/timetable.csv", "/missing.txt"]:
try:
with urllib.request.urlopen(base + path, timeout=5) as response:
body = response.read().decode()
shown = "links: " + ", ".join(re.findall(r'href="([^"]+)"', body)) if path == "/" else body.splitlines()[0]
print(f"{path:15} {response.status} {response.headers.get_content_type():10} {shown}")
except urllib.error.HTTPError as e:
with e:
print(f"{path:15} {e.code} {e.reason}")
server.shutdown()
server.server_close() Output
serving site on 127.0.0.1 / 200 text/html links: notes.txt, timetable.csv /notes.txt 200 text/plain Practicals: Monday and Thursday, 2 to 5 pm /timetable.csv 200 text/csv day,subject,room /missing.txt 404 File not found
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 serve_folder.py
Every file in site became a URL: the listing linked both files, the CSV came back as text/csv, and the missing
file got 404. Two more limits from the documentation matter. The handler follows symbolic links, so a link inside the
folder can serve a file outside it. And the --tls-cert and --tls-key options that Python 3.14 added make the
connection private without changing who may connect.
Serving your home folder
python -m http.server run in your home folder lists everything under it, your documents and your .ssh folder
included, to anyone on the network. Run it in a folder that holds only what you mean to share, and add
--bind 127.0.0.1 unless another device really needs it.
Checkpoint
Check: http.server and who can reach it
A request handler class answers each request
For anything but files, subclass BaseHTTPRequestHandler. The server creates one handler for each request and calls
the method named after the HTTP method, do_GET() for a GET. The handler writes the whole response itself, in order:
send_response() for the status line, send_header() for each header, end_headers() for the blank line that ends
them, then the body to self.wfile:
"""A JSON endpoint with BaseHTTPRequestHandler: one method per HTTP method, and the response written by hand."""
import json
import threading
import urllib.error
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
MARKS = {"21": {"name": "Asha", "maths": 91}, "22": {"name": "Ravi", "maths": 78}}
LOG = []
class MarksHandler(BaseHTTPRequestHandler):
def do_GET(self):
url = urllib.parse.urlsplit(self.path)
roll = urllib.parse.parse_qs(url.query).get("roll", [""])[0]
if url.path == "/health":
self.send_json(200, {"status": "ok"})
elif url.path == "/marks" and roll in MARKS:
self.send_json(200, MARKS[roll])
else:
self.send_json(404, {"error": "not found"})
def send_json(self, status, data):
body = json.dumps(data).encode()
self.send_response(status) # the status line, plus Server and Date headers
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers() # the blank line between headers and body
self.wfile.write(body)
def log_message(self, format, *args): # the default writes each line to stderr after a time stamp
LOG.append(format % args)
server = ThreadingHTTPServer(("127.0.0.1", 0), MarksHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()
base = f"http://127.0.0.1:{server.server_port}"
for path in ["/health", "/marks?roll=21", "/marks?roll=99"]:
try:
with urllib.request.urlopen(base + path, timeout=5) as response:
print(path, response.status, json.loads(response.read()))
except urllib.error.HTTPError as e:
with e:
print(path, e.code, json.loads(e.read()))
try:
urllib.request.urlopen(urllib.request.Request(base + "/marks", data=b"x"), timeout=5)
except urllib.error.HTTPError as e:
with e:
print("POST /marks", e.code, e.reason)
server.shutdown()
server.server_close()
print("the server's log, without its time stamps:")
for line in LOG:
print(" ", line) Output
/health 200 {'status': 'ok'}
/marks?roll=21 200 {'name': 'Asha', 'maths': 91}
/marks?roll=99 404 {'error': 'not found'}
POST /marks 501 Unsupported method ('POST')
the server's log, without its time stamps:
"GET /health HTTP/1.1" 200 -
"GET /marks?roll=21 HTTP/1.1" 200 -
"GET /marks?roll=99 HTTP/1.1" 404 -
code 501, message Unsupported method ('POST')
"POST /marks HTTP/1.1" 501 -
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 json_endpoint.py
The POST got 501, unsupported method, because the class has no do_POST(). The log lines went through
log_message(), which normally writes them to standard error after a time stamp; overriding it is how a program sends
them to its own log. Writing every response by hand is where this approach stops growing well: each route repeats the
same four steps, and nothing checks that the headers match the body.
Checkpoint
Check: Request handler classes
WSGI splits the web app from the server that runs it
PEP 3333 defines the contract in two calls. The server calls app(environ, start_response), where environ is a dict
of the request with keys such as REQUEST_METHOD, PATH_INFO and QUERY_STRING. The app calls
start_response(status, headers) with a status string such as '200 OK' and a list of (name, value) pairs, all of
them str, and returns an iterable of bytes. Step through one request:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A request arrives. The client sends GET /marks?roll=22 to the server's port, as any browser or HTTP client would.
- HTTP becomes a dict. The server reads the request line and the headers and builds environ, a dict in which REQUEST_METHOD is GET, PATH_INFO is /marks and QUERY_STRING is roll=22.
- The server calls the app. It calls the application with environ and a start_response function of its own. The app never sees a socket.
- Status and headers. The app finds roll 22, encodes the JSON, and calls start_response() with '200 OK' and a list of header pairs, every name and value a str.
- The body, as bytes. It returns a list with one bytes object. The server does not care how the app made it, only that it is an iterable of bytes.
- Back to HTTP. The server writes the status line, the headers and the body to the connection. A test can skip the network entirely and call the app with a dict of its own, as call_app.py does.
wsgi/call_app.py
"""Calling the WSGI app directly, as a test does: no server, no network, just a dict in and bytes out."""
from wsgiref.util import setup_testing_defaults
from marks_app import app
def call(app, path, query=""):
environ = {"PATH_INFO": path, "QUERY_STRING": query}
setup_testing_defaults(environ) # fills in REQUEST_METHOD, SERVER_NAME, wsgi.input and the rest
seen = {}
def start_response(status, headers, exc_info=None):
seen["status"], seen["headers"] = status, headers
body = b"".join(app(environ, start_response))
return seen["status"], dict(seen["headers"]), body
for path, query in [("/health", ""), ("/marks", "roll=21"), ("/marks", "roll=99")]:
status, headers, body = call(app, path, query)
print(f"{path}?{query}".rstrip("?"), "->", status, headers["Content-Type"], body.decode()) Output
/health -> 200 OK application/json {"status": "ok"}
/marks?roll=21 -> 200 OK application/json {"name": "Asha", "maths": 91}
/marks?roll=99 -> 404 Not Found application/json {"error": "not found"}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 call_app.py
wsgi/marks_app.py
"""A WSGI application: one callable that takes the request as a dict and returns the body as bytes."""
import json
from urllib.parse import parse_qs
MARKS = {"21": {"name": "Asha", "maths": 91}, "22": {"name": "Ravi", "maths": 78}}
def app(environ, start_response):
path = environ["PATH_INFO"]
roll = parse_qs(environ.get("QUERY_STRING", "")).get("roll", [""])[0]
if path == "/health":
status, data = "200 OK", {"status": "ok"}
elif path == "/marks" and roll in MARKS:
status, data = "200 OK", MARKS[roll]
else:
status, data = "404 Not Found", {"error": "not found"}
body = json.dumps(data).encode()
start_response(status, [("Content-Type", "application/json"), ("Content-Length", str(len(body)))])
return [body] Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
call_app.py is a whole test harness: setup_testing_defaults() fills in the keys a server would add, and its
start_response only records what the app passes. Because the app is a plain function, these tests take
milliseconds, open no port and run in your browser. Press Edit, change the last roll from 99 to 22 and run it again.
Four things stand between this function and a public website:
- wsgiref:
wsgiref.simple_serverruns a WSGI app on a real port for local tests, andwsgiref.validatechecks that an app follows PEP 3333. - Middleware: a WSGI app that wraps another and adds something to every route, such as headers or error pages.
- Production servers: Gunicorn, Waitress and mod_wsgi run the same app with many workers, timeouts and logs.
- Frameworks: Flask and Django are WSGI apps that add routing, templates and sessions; FastAPI uses ASGI, the asynchronous interface.
Key idea
A WSGI app is a function of environ and start_response that returns bytes, so a test can call it with a dict,
with no server and no port.
Checkpoint
Check: The WSGI contract
Practise: a results API as a WSGI app
Write a WSGI app that answers /health and /results?roll=… with JSON, and the right status for a missing roll, an
unknown one and a POST. The sample tests call it with dicts, as call_app.py does, which is why they run in your browser. When they
pass, a practice session asks one question at a time, weakest idea first, and explains each answer as you give it.
Exercise · Medium · Python
A results API as a WSGI app
Finish the WSGI application app(environ, start_response) in results_app.py. The tests call it directly with an environ dict, as a server would, so nothing here opens a port.
Every answer is JSON made with json.dumps(data, ensure_ascii=False).encode(), so that names such as अदिति stay readable, and carries two headers: Content-Type: application/json and a Content-Length that counts the bytes of the body.
GET /healthanswers200 OKwith{"status": "ok"}.GET /results?roll=21answers200 OKwith the entry ofRESULTSfor that roll.GET /resultswithout a roll answers400 Bad Requestwith{"error": "roll is required"}.- An unknown roll, or any other path, answers
404 Not Foundwith{"error": "not found"}. - Any method other than GET answers
405 Method Not Allowedwith{"error": "use GET"}and the extra headerAllow: GET.
Starter code · results_app.py
import json
from urllib.parse import parse_qs
RESULTS = {"21": {"name": "Asha", "total": 452}, "22": {"name": "अदिति", "total": 471}}
def app(environ, start_response):
"""/health, /results?roll=<n>; 400 without a roll, 404 when not found, 405 for methods other than GET."""
body = json.dumps({"status": "ok"}).encode()
start_response("200 OK", [("Content-Type", "application/json")])
return [body] The sample tests · test_results_app.py
import json
from wsgiref.util import setup_testing_defaults
from results_app import app
def call(path, query="", method="GET"):
environ = {"PATH_INFO": path, "QUERY_STRING": query, "REQUEST_METHOD": method}
setup_testing_defaults(environ)
seen = {}
def start_response(status, headers, exc_info=None):
seen["status"], seen["headers"] = status, dict(headers)
body = b"".join(app(environ, start_response))
return seen["status"], seen["headers"], body
def test_health():
"""/health answers 200 with JSON and the right length"""
status, headers, body = call("/health")
assert status == "200 OK"
assert headers["Content-Type"] == "application/json"
assert json.loads(body) == {"status": "ok"}
assert headers["Content-Length"] == str(len(body))
def test_result_found():
"""a known roll answers 200 with its entry, and Content-Length counts bytes"""
status, headers, body = call("/results", "roll=22")
assert status == "200 OK"
assert json.loads(body) == {"name": "अदिति", "total": 471}
assert "अदिति".encode() in body
assert headers["Content-Length"] == str(len(body))
def test_missing_roll():
"""/results without a roll answers 400"""
status, headers, body = call("/results")
assert status == "400 Bad Request"
assert json.loads(body) == {"error": "roll is required"}
def test_not_found():
"""an unknown roll and an unknown path answer 404"""
for path, query in [("/results", "roll=99"), ("/nowhere", "")]:
status, headers, body = call(path, query)
assert status == "404 Not Found"
assert json.loads(body) == {"error": "not found"}
def test_other_methods():
"""POST answers 405 with an Allow header"""
status, headers, body = call("/results", "roll=21", method="POST")
assert status == "405 Method Not Allowed"
assert headers["Allow"] == "GET"
assert json.loads(body) == {"error": "use GET"} A hint
Write one small reply(status, data, extra=()) inside app() that encodes the body, builds the header list and calls start_response(); every branch then ends with return reply(...). Read the roll with parse_qs(environ.get("QUERY_STRING", "")).get("roll", [""])[0].
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Practice that adapts to you
Practise this lesson
9 questions about 8 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Running and checking a WSGI app: wsgiref, validation and request bodies Pro: opens with a Pro, Premium or Ultimate pass
A WSGI app that passes its own tests can still break under a real server: a header of the wrong type, a length counted in characters, a request body that never ends. This part serves the app on a real port, reproduces five bugs and their fixes, and reads request bodies within a limit.
In this part:
- The same app on wsgiref, from socket to environ
- Five bugs, and what wsgiref.validate says about each
- Request bodies: the length header, a size limit and the input stream
4 runnable examples (Python) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
From a development server to production: middleware, workers and frameworks Pro: opens with a Pro, Premium or Ultimate pass
A production setup wraps the same WSGI app in layers: middleware for what every route needs, a server with workers, and a proxy in front. This part builds a middleware that adds security headers and safe error pages, sizes a worker pool with numbers, and sets out what frameworks and ASGI add.
In this part:
- Middleware: security headers and a safe 500 around every route
- Workers, slow clients and a proxy in front, with numbers
- What frameworks add, and when ASGI fits better
1 diagram · 1 runnable example (Python) · about 8 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
What does a WSGI server pass to your application, and what must the application return?
The server calls the application with two arguments. environ is a dict that describes the request: keys such as REQUEST_METHOD, PATH_INFO, QUERY_STRING and CONTENT_LENGTH, one HTTP_ key for each request header (User-Agent arrives as HTTP_USER_AGENT), and wsgi.input, a stream that holds the body. start_response is a function. The application calls start_response(status, headers) once, with a status string such as '200 OK' and a list of (name, value) pairs in which every name and value is a str, and then returns an iterable of bytes, usually a list with one bytes object. Because the application is a plain function, a test can call it with a dict and check the status, the headers and the bytes, with no server, no socket and no port.
-
Fresher to mid
A teammate ran python -m http.server in the project folder at a hackathon to show a build to one judge, and left it running all afternoon. The terminal log shows requests from 40 addresses, some for files outside the build. What was exposed, and what do you do now?
-
Mid to senior
Users with Hindi and Tamil names say your WSGI JSON API sometimes returns cut-off answers, while English names always work. The app sets Content-Length itself. Find the cause, fix it, and say which test would have caught it.
-
Mid to senior
A WSGI service on nine sync workers handles 200 requests a second at 40 ms each. When 20 students upload 2 MB photos from slow phones at 50 KB a second, every other page times out while the CPU stays at 10 %. Explain what is happening and how you fix it.
Key takeaways
python -m http.serverbinds to every interface unless you pass--bind 127.0.0.1, follows symbolic links, and is for development only.- A
BaseHTTPRequestHandlerwrites each response by hand: status line, headers, a blank line, then the body. - A WSGI app is a function of
environandstart_responsethat returns an iterable of bytes, with every header astr. - Because a WSGI app is a plain function, a test can call it with a dict, without a server or a port.
References
- http.server: HTTP servers (Python Software Foundation)
- wsgiref: WSGI utilities and reference implementation (Python Software Foundation)
- PEP 3333: Python Web Server Gateway Interface v1.0.1 (Python Software Foundation)
- Security considerations of the standard library (Python Software Foundation)
- RFC 9110: HTTP semantics (413 Content Too Large) (IETF)
- Gunicorn: design (workers, buffering proxy) (The Gunicorn project)
- Flask: deploying to production (Pallets)
- Django: how to deploy with WSGI (Django Software Foundation)
- FastAPI: run a server manually (The FastAPI project)
- ASGI documentation: introduction (The ASGI project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress