Theory time is over. In this lesson, Papyrus opens its first door to the world: you'll install Flask, write a complete web application in five lines and, when you open the browser, see Ana's catalog — The Odyssey, Hamlet, Don Quixote, Faust — served over HTTP from your own machine. Along the way you'll discover that you already knew Flask's central piece without realizing it (@app.route is one of the decorators from 08-02), you'll learn to generate HTML with Jinja2 templates instead of concatenating strings, and you'll build a search feature that reuses — without touching a single line — the find_book() you wrote in M3 and armor-plated with tests in M9. That's the deep message of this lesson: the web is a new layer on top of the same old code.
Contents
- Installation in the virtual environment
- The minimal application: five lines, line by line
@app.routeis a decorator (and you already know what that means)- The development server and the danger of
debug=True - Routes with variables:
/book/<title> - Jinja2 templates: HTML with holes
- Template inheritance:
base.html - Static files: a minimal CSS
- The search feature: a GET form on top of
find_book() - A sensible project structure
Installation in the virtual environment
Like any package since M1: virtual environment activated, pip install:
Check the installation with flask --version. We'll work in a papyrus_web_flask/ folder that contains, alongside the new web code, the papyrus/ package from M3–M9 exactly as it is and its data/catalog.json. Don't copy individual functions: copy (or link) the whole package; the M9 pytest suite keeps passing and keeps protecting you.
The minimal application: five lines, line by line
Create app.py:
from flask import Flask
app = Flask(__name__)
@app.route("/")
def home():
return "<h1>Papyrus, your neighborhood bookshop</h1><p>Welcome.</p>"Every line deserves its explanation:
from flask import Flask— imports the framework's central class.app = Flask(__name__)— creates the application.__name__(M3: it holds"__main__"or the module's name) tells Flask where your code lives, so it can locate thetemplates/andstatic/folders relative to it.@app.route("/")— registers the function for the route/(the site root). It's the magic piece, and we take it apart in the next section.def home():— a perfectly ordinary Python function. In Flask jargon it's called a view.return "<h1>...</h1>"— whatever the view returns becomes the body of the HTTP response, with an automatic200 OKcode. The browser receives that HTML and renders it.
That's an entire web application: it receives GET / requests and responds with HTML. Compare it mentally with lesson 10-01: Flask is doing the HTTP plumbing (parsing the request, assembling the response, headers, status code) so that you only write the function.
@app.route is a decorator (and you already know what that means)
Pause on @app.route("/"). That syntax is exactly the decorators-with-arguments from lesson 08-02: a function that receives your function and registers or wraps it. Without the syntactic sugar, the code is equivalent to:
def home():
return "<h1>Papyrus...</h1>"
home = app.route("/")(home) # what the @ does under the hood (08-02)app.route("/") doesn't execute your view: it records it in an internal table — "when a request for / arrives, call home()" — and hands it back to you untouched. It's the registration decorator pattern you saw in 08-02, the same one @pytest.fixture used in M9. Back when you wondered what decorators were for beyond timed(): this. Entire frameworks are built on them.
flowchart LR
A["GET /book/Hamlet"] --> B{"Flask's route table"}
B -->|"/"| C["home()"]
B -->|"/book/<title>"| D["detail('Hamlet')"]
B -->|"no match"| E["automatic 404"]
D --> F["return HTML → 200 response"]
The development server and the danger of debug=True
There are two equivalent ways to start the application. The modern one, from the terminal:
Or by adding the M3 if __name__ == "__main__": block at the end of app.py:
and running python app.py. Either way you'll see something like:
Open http://127.0.0.1:5000 in your browser: there's your page. 127.0.0.1 is your own machine (localhost) and 5000 is the port — the URL pieces from 10-01. Debug mode gives you two gifts during development: automatic reloading whenever you save changes (goodbye to restarting by hand) and error pages with the full traceback (M7) in the browser, complete with an interactive console.
And right there is the danger, stated bluntly: never debug=True on a server reachable from outside. That in-browser interactive console executes arbitrary Python code on your server: to an attacker it's a door flung wide open. Besides, this development server isn't built for real traffic; in production you use a WSGI server such as gunicorn behind nginx — we mention it here for honesty's sake and will brush against it in 10-05, but production deployment is beyond the scope of this course.
Routes with variables: /book/<title>
The catalog has four books and you're not going to write four views. Routes accept variable parts between angle brackets, which Flask extracts and passes as an argument:
from papyrus.warehouse import load_catalog, find_book
catalog = load_catalog("data/catalog.json")
@app.route("/book/<title>")
def detail(title):
book = find_book(catalog, title)
if book is None:
return "<p>We don't have that book.</p>", 404
return (f"<h1>{book.title}</h1>"
f"<p>Price: {book.price:.2f} EUR — "
f"member: {book.final_price(member=True):.2f} EUR</p>"
f"<p>Stock: {book.stock}</p>")Key points:
GET /book/Hamletexecutesdetail("Hamlet"): the route variable becomes the function parameter, with the same name.- Pure reuse:
load_catalog,find_bookandfinal_priceare the M5–M6 ones, untouched. The view only translates between HTTP and your package. Hamlet answers9.95 EUR — member: 9.83 EUR, the figures your M9 suite watches over. - Returning a tuple
(body, code)sets the status code: here, the404from 10-01 when the book doesn't exist. (In 10-03 we'll do this translation more elegantly withget_bookand its exceptions.) - You can require a type:
/book/<int:position>would only match integers. For titles the default (text) converter is fine.
Jinja2 templates: HTML with holes
Building HTML with f-strings scales terribly and is dangerous (if a title contained <script>, you'd inject it straight into the page). The framework's answer is templates: HTML files with holes that Flask fills in with your data using the Jinja2 engine. They live in the templates/ folder, a sibling of app.py.
templates/home.html:
<h1>Papyrus Catalog</h1>
<table>
<tr><th>Title</th><th>Price</th><th>Member price</th><th>Stock</th></tr>
{% for book in books %}
<tr>
<td><a href="/book/{{ book.title }}">{{ book.title }}</a></td>
<td>{{ "%.2f"|format(book.price) }} EUR</td>
<td>{{ "%.2f"|format(book.final_price(member=True)) }} EUR</td>
<td>{% if book.in_stock() %}{{ book.stock }}{% else %}Out of stock{% endif %}</td>
</tr>
{% endfor %}
</table>And the view is reduced to handing over the data:
from flask import render_template
@app.route("/")
def home():
return render_template("home.html", books=list(catalog.values()))Jinja2's grammar has two delimiters, and they cover almost everything:
| Syntax | What it is | Example |
|---|---|---|
{{ expression }} |
Hole: it's evaluated and printed | {{ book.title }} |
{% statement %} |
Logic: loops and conditionals, doesn't print | {% for %}...{% endfor %}, {% if %} |
| `{{ value | filter }}` | Filter: transforms before printing |
Inside the holes you can access attributes (book.stock) and call methods (book.in_stock()) on your objects: templates see your M5 dataclasses directly. Every keyword argument of render_template (here books=...) becomes a variable available inside the template — the context. And the security gift: Jinja2 escapes HTML automatically inside {{ }}; a malicious title containing <script> would be displayed as harmless text.
Template inheritance: base.html
All Papyrus pages will share a header, a menu and a footer. Repeating them in every template would be the same sin as repeating code in M3. Jinja2 solves it with inheritance — yes, the concept is a cousin of the one from M5: a base template defines the structure and overridable blocks.
templates/base.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{% block title %}Papyrus{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
</head>
<body>
<header><a href="/">Papyrus</a> — your neighborhood bookshop</header>
<main>{% block content %}{% endblock %}</main>
<footer>Papyrus · Ana and members · since M1</footer>
</body>
</html>And home.html now inherits and fills in only its blocks:
{% extends "base.html" %}
{% block title %}Catalog — Papyrus{% endblock %}
{% block content %}
<h1>Papyrus Catalog</h1>
<table> ... the table from before ... </table>
{% endblock %}{% extends %} must be the first line; each {% block %} in the child replaces the one with the same name in the base. Adding a new page now costs five lines and comes out uniform.
Static files: a minimal CSS
CSS, images and other files served as-is go in static/. In base.html we already linked it with url_for('static', filename='styles.css') — url_for builds URLs from names, better than writing them by hand. A minimal static/styles.css so Papyrus doesn't look like it's from 1996:
body { font-family: Georgia, serif; max-width: 52rem; margin: 2rem auto; }
header, footer { color: #666; border-bottom: 1px solid #ddd; padding: .5rem 0; }
table { border-collapse: collapse; width: 100%; }
td, th { border: 1px solid #ddd; padding: .4rem .8rem; text-align: left; }This isn't a CSS course (the promise made in 10-01 still stands): this is enough.
The search feature: a GET form on top of find_book()
The home page needs the search box Julia will use from her phone. In home.html, inside the content block:
<form action="/search" method="get">
<input type="text" name="title" placeholder="Title...">
<button type="submit">Search</button>
</form>It's the GET form from 10-01: searching for "odyssey" makes the browser request GET /search?title=odyssey. To read that query string, Flask offers the request object:
from flask import request
@app.route("/search")
def search():
term = request.args.get("title", "").strip()
book = find_book(catalog, term)
return render_template("search.html", term=term, book=book)request.argsis a dictionary (M4) with the query string parameters;.get()with a default value avoids theKeyErrorif someone visits/searchwith no parameter — a defensive reflex from M7.- Once again, the logic is the same as always:
find_book()returnsBook | None(M8 put that type hint there), and the template decides what to render:
{% extends "base.html" %}
{% block content %}
{% if book %}
<h1>{{ book.title }}</h1>
<p>{{ "%.2f"|format(book.price) }} EUR — {{ book.stock }} left</p>
{% else %}
<p>No results for "{{ term }}". Try another title.</p>
{% endif %}
{% endblock %}With this, Papyrus web version 1 is complete: a home page with the catalog and search box, and a page per book.
A sensible project structure
For an application this size, this layout is idiomatic and will carry you through 10-03:
papyrus_web_flask/ ├── .venv/ # virtual environment (M1) — outside version control ├── app.py # the Flask application: routes and views ├── papyrus/ # THE M3-M9 PACKAGE, unchanged │ ├── models.py # Book, final_price, in_stock │ ├── warehouse.py # find_book, sell, load_catalog... │ ├── errors.py # PapyrusError and family │ └── coupons.py ├── data/ │ ├── catalog.json │ └── papyrus.log ├── templates/ │ ├── base.html │ ├── home.html │ └── search.html ├── static/ │ └── styles.css └── tests/ # the M9 suite keeps watch
The golden rule of separation: app.py doesn't compute prices or touch stock; it only translates HTTP ↔ the papyrus package. If tomorrow the VAT changes, you touch models.py, the M9 suite verifies it, and the web layer never even notices. When app.py grows, Flask offers blueprints to split it up; knowing they exist is enough for now.
Common Mistakes and Tips
TemplateNotFound: home.html: the folder must be called exactlytemplates/and sit next toapp.py(that's whyFlask(__name__)). Same remedy for static files withstatic/.- You edit and the browser doesn't change: either you didn't start with
--debug(no automatic reloading), or it's the browser cache — reload withCtrl+F5. Address already in use: you left another server running on 5000. Close it, or start on another port:flask --app app run --debug --port 5001.- Returning objects directly:
return bookfails — a view returns text/HTML (or JSON, in 10-03), not aBook. The template is what presents the object. - Business logic in views: if you catch yourself computing discounts in
app.py, stop. That belongs inpapyrus/, where it's tested. The view asks, the package resolves, the template renders. - Forgetting the 404: if
find_bookreturnsNoneand you don't check for it, the template will blow up with a cryptic error. Always handle the empty case — M7 trained you for exactly this.
Exercises
Exercise 1: an "About us" page
Add the route /members with a members.html template that inherits from base.html and shows a <ul> list of Papyrus's members generated with {% for %} from a list of tuples passed in from the view: [("Luis", "LUIS-001"), ("Marta", "MARTA-002"), ("Pau", "PAU-003")].
Exercise 2: member price on demand
Modify the search view to accept a second optional query string parameter, member (/search?title=faust&member=yes). If it equals "yes", the template must show the member price (final_price(member=True)) instead of the regular one. Check that Faust shows 21.00 EUR without the parameter and 20.75 EUR with it.
Exercise 3: book page with a template
The detail view in this lesson still returns f-strings. Refactor it to use a book.html template that inherits from base.html, shows title, price, member price and stock (with "Out of stock" if in_stock() is false), and returns the tuple (render_template("not_found.html", title=title), 404) when the book doesn't exist.
Solutions
Exercise 1:
@app.route("/members")
def members():
people = [("Luis", "LUIS-001"), ("Marta", "MARTA-002"), ("Pau", "PAU-003")]
return render_template("members.html", members=people){% extends "base.html" %}
{% block content %}
<h1>The members of Papyrus</h1>
<ul>
{% for name, code in members %}
<li>{{ name }} — card {{ code }}</li>
{% endfor %}
</ul>
{% endblock %}The name, code unpacking in Jinja2's for works just like Python's (M4).
Exercise 2:
@app.route("/search")
def search():
term = request.args.get("title", "").strip()
is_member = request.args.get("member") == "yes"
book = find_book(catalog, term)
return render_template("search.html", term=term,
book=book, is_member=is_member)In the template: {{ "%.2f"|format(book.final_price(member=is_member)) }} EUR. Note: final_price(member=False) returns the VAT-inclusive price from M5, so "no parameter" shows the regular selling price; with member=yes, Faust drops to 20.75 — one of the four canonical figures from the M9 suite.
Exercise 3:
@app.route("/book/<title>")
def detail(title):
book = find_book(catalog, title)
if book is None:
return render_template("not_found.html", title=title), 404
return render_template("book.html", book=book)book.html reuses the same holes you already wrote in search.html; not_found.html can be three lines with "We don't have '{{ title }}'". The essential bit: the 404 code travels alongside the template in the tuple, so that clients (and search engines) know the page doesn't exist.
Conclusion
Papyrus is now a web application: flask run, and the entire catalog — with member prices computed by M5's final_price() and watched over by M9's tests — is served at http://127.0.0.1:5000 with a home page, a search feature and a page per book. You learned that @app.route is a registration decorator from 08-02's family, that debug mode is gold in development and poison in production, that routes with <variables> turn URLs into function arguments, and that Jinja2 (holes {{ }}, logic {% %}, inheritance with base.html) generates safe HTML from your own objects. Above all, you verified the module's guiding principle: the web layer only translates; the logic stays in the papyrus package, intact and tested. But this website speaks HTML, the language of browsers. The mobile app Julia dreams of for reserving books doesn't want HTML tables: it wants data — clean JSON, precise status codes, predictable routes. That's a REST API, and building one with Flask — connecting BookNotFoundError to the 404 and asdict() to jsonify — is exactly the next lesson.
Python Programming Course
Module 1: Introduction to Python
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
