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

  1. Installation in the virtual environment
  2. The minimal application: five lines, line by line
  3. @app.route is a decorator (and you already know what that means)
  4. The development server and the danger of debug=True
  5. Routes with variables: /book/<title>
  6. Jinja2 templates: HTML with holes
  7. Template inheritance: base.html
  8. Static files: a minimal CSS
  9. The search feature: a GET form on top of find_book()
  10. A sensible project structure

Installation in the virtual environment

Like any package since M1: virtual environment activated, pip install:

python -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install flask

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 the templates/ and static/ 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 automatic 200 OK code. 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/&lt;title&gt;"| 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:

flask --app app run --debug

Or by adding the M3 if __name__ == "__main__": block at the end of app.py:

if __name__ == "__main__":
    app.run(debug=True)

and running python app.py. Either way you'll see something like:

 * Running on http://127.0.0.1:5000
 * Debug mode: on

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/Hamlet executes detail("Hamlet"): the route variable becomes the function parameter, with the same name.
  • Pure reuse: load_catalog, find_book and final_price are the M5–M6 ones, untouched. The view only translates between HTTP and your package. Hamlet answers 9.95 EUR — member: 9.83 EUR, the figures your M9 suite watches over.
  • Returning a tuple (body, code) sets the status code: here, the 404 from 10-01 when the book doesn't exist. (In 10-03 we'll do this translation more elegantly with get_book and 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.args is a dictionary (M4) with the query string parameters; .get() with a default value avoids the KeyError if someone visits /search with no parameter — a defensive reflex from M7.
  • Once again, the logic is the same as always: find_book() returns Book | 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 exactly templates/ and sit next to app.py (that's why Flask(__name__)). Same remedy for static files with static/.
  • 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 with Ctrl+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 book fails — a view returns text/HTML (or JSON, in 10-03), not a Book. The template is what presents the object.
  • Business logic in views: if you catch yourself computing discounts in app.py, stop. That belongs in papyrus/, where it's tested. The view asks, the package resolves, the template renders.
  • Forgetting the 404: if find_book returns None and 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

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved