Serving Static Files
Replace the routes table with real files on disk and label each response with the right Content-Type.
Our pages currently live inside the Python code as strings. That works for two pages, but a real site is made of files: pages, stylesheets, images. Nobody wants to rewrite those as strings.
In this lesson, we teach the server to turn a target into a file on disk and send its contents. These are called static files, because the server sends them exactly as they are stored, without changing them.
The idea is simple. We put the files in a folder, and a request for /index.html sends back public/index.html.
Create a Public Folder
Next to server.py, create a folder named public with two files inside:
server.py
public/
index.html
style.css<!DOCTYPE html>
<html lang="en">
<head>
<title>Server Message</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>Hi from the file</h1>
</body>
</html>h1 { color: teal; }We only serve what is inside public. Everything else on your computer, including server.py, stays private.
Find the File for a Target
At the top of server.py, add an import and two constants:
from pathlib import Path
PUBLIC_DIR = (Path(__file__).parent / "public").resolve()
CONTENT_TYPES = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".txt": "text/plain; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
}PUBLIC_DIR is the full path of our folder. CONTENT_TYPES connects a file extension to the Content-Type header. The browser needs this label to know what it received: a page to show, a stylesheet to apply, or an image to draw.
Now replace ROUTES and route() with serve():
def serve(target: str) -> tuple[int, str, bytes, str]:
path = target.split("?", 1)[0]
if path == "/":
path = "/index.html"
file = (PUBLIC_DIR / path.lstrip("/")).resolve()
if PUBLIC_DIR not in file.parents or not file.is_file():
return 404, "Not Found", b"404 Not Found", "text/plain; charset=utf-8"
content_type = CONTENT_TYPES.get(file.suffix, "application/octet-stream")
return 200, "OK", file.read_bytes(), content_typeLet's walk through it:
- Find the path. : We cut off the query string, like we did for routes.
- Choose the home page. : A request for
/has no file name, so we useindex.html. - Find the file. : We join the path to
PUBLIC_DIR.lstrip("/")removes the leading slash, so/style.cssbecomesstyle.css.resolve()turns the result into a full path. - Check it. : If the file is not inside
PUBLIC_DIR, or is not a real file, we answer404 Not Found. - Pick the Content-Type. : We look up the extension. If we don't know it, we use
application/octet-stream, which means "some bytes, type unknown". - Send the bytes. :
read_bytes()returns the file's contents as bytes, which is exactly whatbuild_response()expects.
The function now returns four values: the status, the reason, the body and the content type.
Use It in main
In main(), call serve() instead of route(), and pass the content type to build_response().
Only the end of the with conn: block changes:
status, reason, body, content_type = serve(target)
print(f" -> {status} {reason}")
response = build_response(
status, reason, body, content_type, head_only=(method == "HEAD")
)
conn.sendall(response)Run The Server
Here is the full file:
import socket
from pathlib import Path
HOST = "127.0.0.1"
PORT = 8000
PUBLIC_DIR = (Path(__file__).parent / "public").resolve()
CONTENT_TYPES = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".txt": "text/plain; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
}
def read_request(conn: socket.socket) -> bytes:
data = b""
while b"\r\n\r\n" not in data:
chunk = conn.recv(1024)
if not chunk: # the client closed the connection
break
data += chunk
return data
def parse_request(raw: bytes) -> tuple[str, str, str, dict[str, str]]:
if b"\r\n\r\n" not in raw:
raise ValueError("incomplete request")
head = raw.split(b"\r\n\r\n", 1)[0].decode("iso-8859-1")
lines = head.split("\r\n")
parts = lines[0].split(" ")
if len(parts) != 3:
raise ValueError("malformed request line")
method, target, version = parts
headers = {}
for line in lines[1:]:
name, sep, value = line.partition(":")
if not sep:
raise ValueError("malformed header")
headers[name.strip().lower()] = value.strip()
return method, target, version, headers
def build_response(
status: int,
reason: str,
body: bytes,
content_type: str = "text/plain; charset=utf-8",
head_only: bool = False,
) -> bytes:
head = (
f"HTTP/1.1 {status} {reason}\r\n"
f"Content-Type: {content_type}\r\n"
f"Content-Length: {len(body)}\r\n"
"Connection: close\r\n"
"\r\n"
).encode("iso-8859-1")
return head if head_only else head + body
def serve(target: str) -> tuple[int, str, bytes, str]:
path = target.split("?", 1)[0]
if path == "/":
path = "/index.html"
file = (PUBLIC_DIR / path.lstrip("/")).resolve()
if PUBLIC_DIR not in file.parents or not file.is_file():
return 404, "Not Found", b"404 Not Found", "text/plain; charset=utf-8"
content_type = CONTENT_TYPES.get(file.suffix, "application/octet-stream")
return 200, "OK", file.read_bytes(), content_type
def main() -> None:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((HOST, PORT))
server.listen()
print(f"listening on http://{HOST}:{PORT} (Press Ctrl+c to stop)")
try:
while True:
conn, addr = server.accept()
with conn:
print(f"connection from {addr}")
raw = read_request(conn)
try:
method, target, version, headers = parse_request(raw)
except ValueError as exc:
print(f"bad request: {exc}")
continue
print(f"{method} {target} {version}")
for name, value in headers.items():
print(f" {name}: {value}")
status, reason, body, content_type = serve(target)
print(f" -> {status} {reason}")
response = build_response(
status, reason, body, content_type, head_only=(method == "HEAD")
)
conn.sendall(response)
except KeyboardInterrupt:
print("\nbye")
if __name__ == "__main__":
main()The default content type is now text/plain, so function build_response() no longer assumes HTML.
Start the server and from a second terminal, ask for the stylesheet:
curl -i http://127.0.0.1:8000/style.cssExpected output:
HTTP/1.1 200 OK
Content-Type: text/css; charset=utf-8
Content-Length: 20
Connection: close
h1 { color: teal; }Content-Length is the file's size in bytes. Yours may differ by one byte, depending on whether your editor adds a newline at the end. You can check with wc -c public/style.css.
Now ask for a file that doesn't exist:
curl -i http://127.0.0.1:8000/missing.htmlExpected output:
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Content-Length: 13
Connection: close
404 Not FoundFinally, try to escape the public folder. The --path-as-is option stops curl from tidying up the .. before it sends the request:
curl --path-as-is -i http://127.0.0.1:8000/../server.pyYou get the same 404 Not Found. The file server.py exists, but it is outside public, so our server refuses to send it.
Now open http://127.0.0.1:8000 in your browser. The page appears with a teal heading. Look at the server's terminal, and you will see something like this:
GET / HTTP/1.1
...
-> 200 OK
GET /style.css HTTP/1.1
...
-> 200 OK
GET /favicon.ico HTTP/1.1
...
-> 404 Not FoundOne page needed three requests. The browser read index.html, found the <link> to style.css, and asked for it on its own.
Our server can now serve a whole folder of files. But we have been testing only the happy path. A request can be broken, a method can be unsupported, and something can go wrong while reading a file. Next, we make the server answer all of these properly.