Serving Data
Goals
- Understand what a web server does and how it differs from opening a file in a browser.
- Use Litestar to create a route that returns a plain-text response.
- Serve a single HTML page built with htpy and styled with an external CSS file.
- Add a second page linked from the first.
What a Web Server Does
What is a web server and how is it different from opening an HTML file in a browser?
- When you double-click an HTML file, the browser reads it from disk: no network involved
- A web server is a program that listens for requests from browsers
and sends back responses
- The browser says "give me the page at
/sightings" - The server reads some data, builds a page, and sends it back
- The browser says "give me the page at
- This matters because a server can generate different pages for different requests, read from a database, and accept form submissions, none of which work with static files
What is Litestar and how do I add it to the project?
- Litestar is a Python web framework:
- A library that handles the low-level details of receiving requests and sending responses so you can focus on your application logic
- Add it to the project with
uv add litestar, then confirm withpython -c "import litestar" - A Litestar application is made of routes
- Each route pairs a URL pattern with the Python function that handles requests to that URL
A First Server
Write a Litestar server with a single route at
/that returns the message "Hello from the Sasquatch Observatory!".
- The
@get("/")decorator marks the function as a handler for GET requests to/ - The function's return type annotation (
-> str) tells Litestar what kind of data to expect Litestar([index])creates the application with a list of handlers- The
asynckeyword tells Python this function can run while waiting on other work; Litestar works with bothasync defand plaindef, but LLMs typically generateasync def
from litestar import Litestar, get
@get("/")
async def index() -> str:
return "Hello from the Sasquatch Observatory!"
app = Litestar([index])
- Run the server from inside the
litestar/directory with the command below- Stop it with Ctrl-C when you are done
- If it doesn't work, check that your uv environment is active
litestar run --app server_hello:app
- The terminal shows
Listening at http://127.0.0.1:8000- Visit that URL in a browser
127.0.0.1is the loopback address (also written aslocalhost)- Your computer talking to itself rather than to a remote machine
8000is the port number, like a door number in a building- Different programs listen on different ports so they do not collide
Explain what happens step by step when the browser visits
http://127.0.0.1:8000.
- The browser opens a connection to port 8000 on the loopback address and sends an HTTP request: "GET / HTTP/1.1"
- Litestar matches the path
/to theindexfunction and calls it - The function returns the string, Litestar wraps it in an HTTP response, and sends it back
- With a 200 status code to indicate that everything is OK
- The browser displays the text in the window
Why index?
The name index comes from the early web, when servers mapped URLs directly to files on disk.
index.html was like the index of a book.
Modern frameworks keep the convention: the function that handles requests for / is called index
because it serves the site's front page.
A Table of Sightings
Create a file called
dataset.pycontaining twenty rows of synthetic sasquatch sighting data. Each row must be a dict with keys for ID, species, sex, weight, color, date/time, latitude, and longitude. Some sex and weight values should beNoneto represent observations where those details were not recorded.
- The LLM produces a list of dicts
- Store it in a variable called
SIGHTINGS - Using
Nonefor missing values is more honest than using an empty string or zero, and it forces you to handle the missing case explicitly in the rest of the code
SIGHTINGS = [
{
"id": 1,
"species": "G. canadensis",
"sex": "Female",
"weight": 142,
"color": "dark brown",
"datetime": "2024-01-08 07:14",
"latitude": 49.23,
"longitude": -121.45,
},
Write a Litestar route at
/that importsSIGHTINGSfromdataset.pyand returns an HTML page with a table of all sightings
- Pass
media_type=MediaType.HTMLto@getso the browser renders the response as HTML rather than displaying the raw tags as text - Display
Nonevalues as an empty string withstr(s[k]) if s[k] is not None else ""
Modify the Litestar route to Link to an external CSS file called
style.css. Also add a route at/style.cssto read and return that file.
- Link to the stylesheet with
link(rel="stylesheet", href="/style.css")in the<head> - When the browser parses that link it makes a second GET request to
/style.css, so the server needs a route to handle it - The CSS route reads the file from disk relative to the server script using
Path(__file__).parent
from pathlib import Path
from htpy import body, head, html, link, table, td, th, title, tr
from litestar import Litestar, MediaType, get
from dataset import SIGHTINGS
from utils import HEADERS, KEYS, fmt
LESSON_DIR = Path(__file__).parent
@get("/", media_type=MediaType.HTML)
async def index() -> str:
return str(
html(lang="en")[
head[
title["Sasquatch Sightings"],
link(rel="stylesheet", href="/style.css"),
],
body[
table[
tr[[th[col] for col in HEADERS]],
[tr[[td[fmt(s[k])] for k in KEYS]] for s in SIGHTINGS],
],
],
]
)
@get("/style.css", media_type="text/css")
async def styles() -> str:
return (LESSON_DIR / "style.css").read_text()
app = Litestar([index, styles])
Explain what
MediaType.HTMLdoes and what the browser would show if it was left out.
- HTTP responses carry a media type (sometimes called a MIME type) that tells the browser what kind of content it is receiving
MediaType.HTMLsets that header totext/html; without it Litestar defaults totext/plain- With
text/plainthe browser treats the content as literal characters, so<table>appears on screen as<table>rather than becoming a rendered table
Write a simple CSS stylesheet that sets the font and page width, puts borders on the table cells, and makes the header row slightly gray.
body {
font-family: sans-serif;
max-width: 960px;
margin: 2rem auto;
padding: 0 1rem;
}
table {
border-collapse: collapse;
width: 100%;
}
th,
td {
border: 1px solid #cccccc;
padding: 0.4rem 0.8rem;
text-align: left;
}
th {
background-color: #f0f0f0;
}
td.label {
font-weight: bold;
width: 30%;
}
Detail Pages
Make each sighting ID in the table a link to
/sighting/{id}. Add a route that looks up that ID and displays the sighting's details in a two-column table with the field names on the left and values on the right.
- A path parameter is a variable segment of the URL:
{sighting_id:int}matches/sighting/1,/sighting/2, and so on, and Litestar converts the captured text to an integer before passing it to the handler - The handler loops through
SIGHTINGSlooking for the matching IDraise NotFoundException(...)sends a 404 response if none is found
- The detail table has one row per field
- Each sighting ID in the index table becomes a link:
a(href=f"/sighting/{s['id']}")[str(s["id"])]
@get("/", media_type=MediaType.HTML)
async def index() -> str:
return str(
html(lang="en")[
head[
title["Sasquatch Sightings"],
link(rel="stylesheet", href="/style.css"),
],
body[
table[
tr[[th[col] for col in HEADERS]],
[
tr[
td[a(href=f"/sighting/{s['id']}")[str(s["id"])]],
[td[fmt(s[k])] for k in KEYS[1:]],
]
for s in SIGHTINGS
],
],
],
]
)
@get("/sighting/{sighting_id:int}", media_type=MediaType.HTML)
async def detail(sighting_id: int) -> str:
for s in SIGHTINGS:
if s["id"] == sighting_id:
return str(
html(lang="en")[
head[
title[f"Sighting {sighting_id}"],
link(rel="stylesheet", href="/style.css"),
],
body[
table[
[
tr[
td(class_="label")[label],
td[fmt(s[key])],
]
for key, label in LABELS.items()
],
],
a(href="/")["Back to all sightings"],
],
]
)
raise NotFoundException(f"No sighting with ID {sighting_id}")
@get("/style.css", media_type="text/css")
async def styles() -> str:
return (LESSON_DIR / "style.css").read_text()
app = Litestar([index, detail, styles])
litestar run --app server_detail:app
Trace what happens when a user visits
/sighting/99and ID 99 is not in the data.
- Litestar matches the URL to
detailand calls it withsighting_id=99 - The
forloop finishes without finding a match, so execution reachesraise NotFoundException - Litestar catches the exception and sends back an HTTP response with status code 404
- The browser shows an error page
- No Python traceback appears in the browser window, but Litestar prints a log line in the terminal
Check Understanding
See [litestar2025] for the full Litestar documentation and [mdn-html2024] for the HTTP reference.
What is the difference between @get("/") and @get("/sightings")?
Both decorate a function as a GET request handler, but they respond to different URLs.
@get("/") handles requests to http://127.0.0.1:8000/ (the root),
while @get("/sightings") handles requests to http://127.0.0.1:8000/sightings.
A Litestar app can have as many routes as you like, each at a different path.
You visit http://127.0.0.1:8000, but the browser shows
"This site can't be reached". What is the most likely cause?
The server is not running: either you have not run litestar run --app server_hello:app,
or it crashed on startup, or you stopped it.
Check the terminal where you launched the server for error messages.
The code below is supposed to show an HTML page, but the browser displays the raw tags as text. What is wrong and how do you fix it?
The route is missing media_type=MediaType.HTML, so Litestar sends the response as text/plain.
The browser therefore treats the content as literal text rather than markup.
The fix is:
@get("/", media_type=MediaType.HTML)
async def index() -> str:
return str(html[body[p["Hello"]]])
@get("/")
async def index() -> str:
return str(html[body[p["Hello"]]])
Why does the browser make two requests when it loads the sightings table page?
The first request is for the page itself (GET /).
The HTML the server returns contains <link rel="stylesheet" href="/style.css">.
When the browser parses that tag, it automatically makes a second request (GET /style.css)
to fetch the stylesheet.
This is why server_table.py needs both an index route and a styles route.
The route below is supposed to display a sighting's details, but visiting
/sighting/abc crashes the server with an unhandled exception. Why, and how does adding :int
to the path parameter fix it?
Without :int, Litestar captures the URL segment as a plain string and passes it to the handler.
When the handler tries to compare it to the integer IDs in SIGHTINGS the logic may fail,
and any code that treats it as a number will raise a ValueError or TypeError.
Writing {sighting_id:int} tells Litestar to convert the segment to an integer before calling the handler.
If the segment is not a valid integer, such as abc, Litestar itself returns a 400 Bad Request response
before your function is ever called.
@get("/sighting/{sighting_id}")
async def detail(sighting_id: str) -> str:
...
Exercises
Add a Heading to Every Page
Add an <h1> heading to the <body> of both the index page and the detail page in server_detail.py.
The index page heading should say "Sasquatch Sightings in British Columbia";
the detail page heading should include the sighting ID, for example "Sighting 7".
Link Back from the Detail Page
The detail page already has a "Back to all sightings" link in server_detail.py.
Open server_detail.py, find where that link is built, and move it above the table
so it appears at the top of the page rather than below it.
Filter by Species
Add a new route at /species/{name} that returns only the sightings whose species field
matches the given name. Display them in the same table format as the index page.
What happens if you visit /species/G.%20canadensis? (The %20 is how a space appears in a URL.)
Show a Count
Add a line below the table on the index page that displays the total number of sightings,
for example "20 sightings on record". Display it in a <p> element.
Style None Differently
Instead of displaying None values as empty strings, display them as the word "unknown"
and add a CSS class unknown to those cells.
Add a rule to style.css that makes cells with class unknown appear in gray italic text.