Fin de la teoría. En esta lección, Papyrus abre su primera puerta al mundo: instalarás Flask, escribirás una aplicación web completa en cinco líneas y, al abrir el navegador, verás el catálogo de Ana — La Odisea, Hamlet, El Quijote, Fausto — servido por HTTP desde tu propia máquina. Por el camino descubrirás que ya conocías la pieza central de Flask sin saberlo (@app.route es un decorador de los de 08-02), aprenderás a generar HTML con plantillas Jinja2 en lugar de concatenar cadenas, y construirás un buscador que reutiliza, sin tocar una línea, el buscar_libro() que escribiste en M3 y blindaste con tests en M9. Ese es el mensaje profundo de la lección: la web es una capa nueva sobre el código de siempre.

Contenido

  1. Instalación en el entorno virtual
  2. La aplicación mínima: cinco líneas, línea a línea
  3. @app.route es un decorador (y tú ya sabes lo que eso significa)
  4. El servidor de desarrollo y el peligro de debug=True
  5. Rutas con variables: /libro/<titulo>
  6. Plantillas Jinja2: HTML con huecos
  7. Herencia de plantillas: base.html
  8. Archivos estáticos: un CSS mínimo
  9. El buscador: un formulario GET sobre buscar_libro()
  10. Estructura de proyecto razonable

Instalación en el entorno virtual

Como cualquier paquete desde M1: entorno virtual activado, pip install:

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

Comprueba la instalación con flask --version. Trabajaremos en una carpeta papyrus_web_flask/ que contiene, junto al nuevo código web, el paquete papyrus/ de M3–M9 tal cual y su datos/catalogo.json. No copies funciones: copia (o enlaza) el paquete entero; la suite de pytest de M9 sigue pasando y sigue protegiéndote.

La aplicación mínima: cinco líneas, línea a línea

Crea app.py:

from flask import Flask

app = Flask(__name__)

@app.route("/")
def inicio():
    return "<h1>Papyrus, librería de barrio</h1><p>Bienvenido.</p>"

Cada línea merece su explicación:

  • from flask import Flask — importa la clase central del framework.
  • app = Flask(__name__) — crea la aplicación. __name__ (M3: vale "__main__" o el nombre del módulo) le dice a Flask dónde está tu código, para que sepa localizar las carpetas templates/ y static/ relativas a él.
  • @app.route("/") — registra la función para la ruta / (la raíz del sitio). Es la pieza mágica y la desmontamos en la sección siguiente.
  • def inicio(): — una función Python normal y corriente. En jerga Flask se llama vista (view).
  • return "<h1>...</h1>" — lo que la vista devuelve se convierte en el cuerpo de la respuesta HTTP, con código 200 OK automático. El navegador recibe ese HTML y lo pinta.

Eso es una aplicación web entera: recibe peticiones GET / y responde HTML. Compárala mentalmente con la lección 10-01: Flask está haciendo de fontanero HTTP (parsear la petición, montar la respuesta, cabeceras, código de estado) para que tú solo escribas la función.

@app.route es un decorador (y tú ya sabes lo que eso significa)

Detente en @app.route("/"). Esa sintaxis es exactamente la de los decoradores con argumentos de la lección 08-02: una función que recibe tu función y la registra o la envuelve. Sin azúcar sintáctico, el código equivale a:

def inicio():
    return "<h1>Papyrus...</h1>"

inicio = app.route("/")(inicio)   # lo que hace el @ por debajo (08-02)

app.route("/") no ejecuta tu vista: la apunta en una tabla interna — "cuando llegue una petición a /, llama a inicio()" — y te la devuelve intacta. Es el patrón decorador de registro que viste en 08-02, el mismo que usaba @pytest.fixture en M9. Cuando en su día te preguntaste para qué servían los decoradores más allá de cronometrar(): para esto. Frameworks enteros se construyen sobre ellos.

flowchart LR
    A["GET /libro/Hamlet"] --> B{"Tabla de rutas de Flask"}
    B -->|"/"| C["inicio()"]
    B -->|"/libro/&lt;titulo&gt;"| D["ficha_libro('Hamlet')"]
    B -->|"sin coincidencia"| E["404 automático"]
    D --> F["return HTML → respuesta 200"]

El servidor de desarrollo y el peligro de debug=True

Para arrancar la aplicación hay dos formas equivalentes. La moderna, desde la terminal:

flask --app app run --debug

O añadiendo al final de app.py el bloque if __name__ == "__main__": de M3:

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

y ejecutando python app.py. En ambos casos verás algo como:

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

Abre http://127.0.0.1:5000 en el navegador: ahí está tu página. 127.0.0.1 es tu propia máquina (localhost) y 5000 el puerto — las piezas de URL de 10-01. El modo debug te da dos regalos durante el desarrollo: recarga automática al guardar cambios (adiós a rearrancar a mano) y páginas de error con el traceback completo (M7) en el navegador, incluso con una consola interactiva.

Y ahí está también el peligro, dicho sin rodeos: debug=True jamás en un servidor accesible desde fuera. Esa consola interactiva del navegador ejecuta código Python arbitrario en tu servidor: para un atacante es una puerta abierta de par en par. Además, este servidor de desarrollo no está pensado para tráfico real; en producción se usa un servidor WSGI como gunicorn detrás de nginx — lo mencionamos aquí por honestidad y lo tocaremos de refilón en 10-05, pero el despliegue en producción queda fuera de este curso.

Rutas con variables: /libro/<titulo>

El catálogo tiene cuatro libros y no vas a escribir cuatro vistas. Las rutas admiten partes variables entre ángulos, que Flask extrae y pasa como argumento:

from papyrus.almacen import cargar_catalogo, buscar_libro

catalogo = cargar_catalogo("datos/catalogo.json")

@app.route("/libro/<titulo>")
def ficha_libro(titulo):
    libro = buscar_libro(catalogo, titulo)
    if libro is None:
        return "<p>No tenemos ese libro.</p>", 404
    return (f"<h1>{libro.titulo}</h1>"
            f"<p>Precio: {libro.precio:.2f} € — "
            f"socio: {libro.precio_final(socio=True):.2f} €</p>"
            f"<p>Stock: {libro.stock}</p>")

Puntos clave:

  • GET /libro/Hamlet ejecuta ficha_libro("Hamlet"): la variable de la ruta se convierte en el parámetro de la función, con el mismo nombre.
  • Reutilización pura: cargar_catalogo, buscar_libro y precio_final son los de M5–M6, intactos. La vista solo traduce entre HTTP y tu paquete. Hamlet responde 9.95 € — socio: 9.83 €, los números que tu suite de M9 vigila.
  • Devolver una tupla (cuerpo, código) fija el código de estado: aquí, el 404 de 10-01 cuando el libro no existe. (En 10-03 haremos esta traducción de forma más elegante con obtener_libro y sus excepciones.)
  • Se puede exigir tipo: /libro/<int:posicion> solo casaría con enteros. Para títulos nos vale el conversor por defecto (texto).

Plantillas Jinja2: HTML con huecos

Construir HTML con f-strings escala fatal y es peligroso (si un título contuviera <script>, lo inyectarías tal cual en la página). La solución del framework son las plantillas: ficheros HTML con huecos que Flask rellena con tus datos usando el motor Jinja2. Van en la carpeta templates/, hermana de app.py.

templates/inicio.html:

<h1>Catálogo de Papyrus</h1>
<table>
    <tr><th>Título</th><th>Precio</th><th>Precio socio</th><th>Stock</th></tr>
    {% for libro in libros %}
    <tr>
        <td><a href="/libro/{{ libro.titulo }}">{{ libro.titulo }}</a></td>
        <td>{{ "%.2f"|format(libro.precio) }} €</td>
        <td>{{ "%.2f"|format(libro.precio_final(socio=True)) }} €</td>
        <td>{% if libro.hay_stock() %}{{ libro.stock }}{% else %}Agotado{% endif %}</td>
    </tr>
    {% endfor %}
</table>

Y la vista se reduce a entregar los datos:

from flask import render_template

@app.route("/")
def inicio():
    return render_template("inicio.html", libros=list(catalogo.values()))

La gramática de Jinja2 tiene dos delimitadores y con ellos va casi todo:

Sintaxis Qué es Ejemplo
{{ expresión }} Hueco: se evalúa y se imprime {{ libro.titulo }}
{% instrucción %} Lógica: bucles y condicionales, no imprime {% for %}...{% endfor %}, {% if %}
`{{ valor filtro }}` Filtro: transforma antes de imprimir

Dentro de los huecos puedes acceder a atributos (libro.stock) y llamar métodos (libro.hay_stock()) de tus objetos: las plantillas ven tus dataclasses de M5 directamente. Cada argumento con nombre de render_template (aquí libros=...) se convierte en una variable disponible en la plantilla — el contexto. Y el regalo de seguridad: Jinja2 escapa el HTML automáticamente en los {{ }}; un título malicioso con <script> se mostraría como texto inofensivo.

Herencia de plantillas: base.html

Todas las páginas de Papyrus compartirán cabecera, menú y pie. Repetirlos en cada plantilla sería el mismo pecado que repetir código en M3. Jinja2 lo resuelve con herencia — sí, el concepto es primo del de M5: una plantilla base define la estructura y bloques sobrescribibles.

templates/base.html:

<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="utf-8">
    <title>{% block titulo %}Papyrus{% endblock %}</title>
    <link rel="stylesheet" href="{{ url_for('static', filename='estilos.css') }}">
</head>
<body>
    <header><a href="/">Papyrus</a> — librería de barrio</header>
    <main>{% block contenido %}{% endblock %}</main>
    <footer>Papyrus · Ana y socios · desde M1</footer>
</body>
</html>

Y inicio.html pasa a heredar y rellenar solo sus bloques:

{% extends "base.html" %}
{% block titulo %}Catálogo — Papyrus{% endblock %}
{% block contenido %}
<h1>Catálogo de Papyrus</h1>
<table> ... la tabla de antes ... </table>
{% endblock %}

{% extends %} debe ser la primera línea; cada {% block %} de la hija sustituye al homónimo de la base. Añadir ahora una página nueva cuesta cinco líneas y sale uniformada.

Archivos estáticos: un CSS mínimo

El CSS, las imágenes y demás ficheros que se sirven tal cual van en static/. En base.html ya lo enlazamos con url_for('static', filename='estilos.css')url_for construye URLs a partir de nombres, mejor que escribirlas a mano. Un static/estilos.css mínimo para que Papyrus no parezca de 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; }

No es un curso de CSS (lo prometido en 10-01 sigue en pie): con esto basta.

El buscador: un formulario GET sobre buscar_libro()

La página de inicio necesita el buscador que Júlia usará desde el móvil. En inicio.html, dentro del bloque de contenido:

<form action="/buscar" method="get">
    <input type="text" name="titulo" placeholder="Título...">
    <button type="submit">Buscar</button>
</form>

Es el formulario GET de 10-01: al buscar "odisea" el navegador pedirá GET /buscar?titulo=odisea. Para leer esa query string, Flask ofrece el objeto request:

from flask import request

@app.route("/buscar")
def buscar():
    termino = request.args.get("titulo", "").strip()
    libro = buscar_libro(catalogo, termino)
    return render_template("buscar.html", termino=termino, libro=libro)
  • request.args es un diccionario (M4) con los parámetros de la query string; .get() con valor por defecto evita el KeyError si alguien visita /buscar sin parámetro — reflejo defensivo de M7.
  • De nuevo, la lógica es la de siempre: buscar_libro() devuelve Libro | None (M8 puso ese type hint), y la plantilla decide qué pintar:
{% extends "base.html" %}
{% block contenido %}
{% if libro %}
    <h1>{{ libro.titulo }}</h1>
    <p>{{ "%.2f"|format(libro.precio) }} € — quedan {{ libro.stock }}</p>
{% else %}
    <p>Sin resultados para «{{ termino }}». Prueba con otro título.</p>
{% endif %}
{% endblock %}

Con esto, la web de Papyrus versión 1 está completa: portada con el catálogo y buscador, y ficha por libro.

Estructura de proyecto razonable

Para una aplicación de este tamaño, esta disposición es idiomática y te acompañará hasta 10-03:

papyrus_web_flask/
├── .venv/                  # entorno virtual (M1) — fuera del control de versiones
├── app.py                  # la aplicación Flask: rutas y vistas
├── papyrus/                # EL PAQUETE DE M3-M9, sin cambios
│   ├── modelos.py          #   Libro, precio_final, hay_stock
│   ├── almacen.py          #   buscar_libro, vender, cargar_catalogo...
│   ├── errores.py          #   ErrorPapyrus y familia
│   └── cupones.py
├── datos/
│   ├── catalogo.json
│   └── papyrus.log
├── templates/
│   ├── base.html
│   ├── inicio.html
│   └── buscar.html
├── static/
│   └── estilos.css
└── tests/                  # la suite de M9 sigue vigilando

La regla de oro de la separación: app.py no calcula precios ni toca stock; solo traduce HTTP ↔ paquete papyrus. Si mañana cambias el IVA, tocas modelos.py, la suite de M9 lo verifica, y la web ni se entera. Cuando app.py crezca, Flask ofrece blueprints para trocearlo; te bastará saber que existen.

Errores Comunes y Consejos

  • TemplateNotFound: inicio.html: la carpeta debe llamarse exactamente templates/ y estar junto a app.py (por eso Flask(__name__)). Mismo remedio para estáticos con static/.
  • Editas y el navegador no cambia: o no arrancaste con --debug (sin recarga automática), o es la caché del navegador — recarga con Ctrl+F5.
  • Address already in use: dejaste otro servidor corriendo en el 5000. Ciérralo, o arranca en otro puerto: flask --app app run --debug --port 5001.
  • Devolver objetos directamente: return libro falla — una vista devuelve texto/HTML (o JSON, en 10-03), no un Libro. La plantilla es quien presenta el objeto.
  • Lógica de negocio en las vistas: si te descubres calculando descuentos en app.py, para. Eso pertenece a papyrus/, donde está testeado. La vista pide, el paquete resuelve, la plantilla pinta.
  • Olvidar el 404: si buscar_libro devuelve None y no lo compruebas, la plantilla petará con un error críptico. Trata siempre el caso vacío — M7 te entrenó exactamente para esto.

Ejercicios

Ejercicio 1: página "Quiénes somos"

Añade la ruta /socios con una plantilla socios.html que herede de base.html y muestre una lista <ul> con los socios de Papyrus generada con {% for %} a partir de una lista de tuplas pasada desde la vista: [("Luis", "LUIS-001"), ("Marta", "MARTA-002"), ("Pau", "PAU-003")].

Ejercicio 2: precio de socio bajo demanda

Modifica la vista buscar para aceptar un segundo parámetro opcional de query string, socio (/buscar?titulo=fausto&socio=si). Si vale "si", la plantilla debe mostrar el precio de socio (precio_final(socio=True)) en lugar del normal. Comprueba que Fausto muestra 21.00 € sin el parámetro y 20.75 € con él.

Ejercicio 3: ficha con plantilla

La vista ficha_libro de esta lección aún devuelve f-strings. Refactorízala para usar una plantilla libro.html que herede de base.html, muestre título, precio, precio de socio y stock (con «Agotado» si hay_stock() es falso), y devuelva la tupla (render_template("no_encontrado.html", titulo=titulo), 404) cuando el libro no exista.

Soluciones

Ejercicio 1:

@app.route("/socios")
def socios():
    lista = [("Luis", "LUIS-001"), ("Marta", "MARTA-002"), ("Pau", "PAU-003")]
    return render_template("socios.html", socios=lista)
{% extends "base.html" %}
{% block contenido %}
<h1>Los socios de Papyrus</h1>
<ul>
{% for nombre, codigo in socios %}
    <li>{{ nombre }} — carné {{ codigo }}</li>
{% endfor %}
</ul>
{% endblock %}

El desempaquetado nombre, codigo en el for de Jinja2 funciona como el de Python (M4).

Ejercicio 2:

@app.route("/buscar")
def buscar():
    termino = request.args.get("titulo", "").strip()
    es_socio = request.args.get("socio") == "si"
    libro = buscar_libro(catalogo, termino)
    return render_template("buscar.html", termino=termino,
                           libro=libro, es_socio=es_socio)

En la plantilla: {{ "%.2f"|format(libro.precio_final(socio=es_socio)) }} €. Nota: precio_final(socio=False) devuelve el precio con IVA de M5, así que "sin parámetro" muestra el precio de venta habitual; con socio=si, Fausto baja a 20.75 — uno de los cuatro números canónicos de la suite de M9.

Ejercicio 3:

@app.route("/libro/<titulo>")
def ficha_libro(titulo):
    libro = buscar_libro(catalogo, titulo)
    if libro is None:
        return render_template("no_encontrado.html", titulo=titulo), 404
    return render_template("libro.html", libro=libro)

libro.html reutiliza los mismos huecos que ya escribiste en buscar.html; no_encontrado.html puede ser tres líneas con «No tenemos «{{ titulo }}»». Lo esencial: el código 404 viaja junto a la plantilla en la tupla, para que los clientes (y los buscadores) sepan que la página no existe.

Conclusión

Papyrus ya es una aplicación web: flask run, y el catálogo entero — con precios de socio calculados por el precio_final() de M5 y vigilados por los tests de M9 — se sirve en http://127.0.0.1:5000 con portada, buscador y ficha por libro. Aprendiste que @app.route es un decorador de registro de los de 08-02, que el modo debug es oro en desarrollo y veneno en producción, que las rutas con <variables> convierten URLs en argumentos de función, y que Jinja2 (huecos {{ }}, lógica {% %}, herencia con base.html) genera HTML seguro a partir de tus propios objetos. Sobre todo, comprobaste el principio rector del módulo: la capa web solo traduce; la lógica sigue en el paquete papyrus, intacta y testeada. Pero esta web habla HTML, el idioma de los navegadores. La app móvil que Júlia sueña para reservar libros no quiere tablas HTML: quiere datos — JSON limpio, códigos de estado precisos, rutas predecibles. Eso es una API REST, y construirla con Flask, conectando LibroNoEncontradoError con el 404 y asdict() con jsonify, es exactamente la próxima lección.

Curso de Programación en Python

Módulo 1: Introducción a Python

Módulo 2: Estructuras de Control

Módulo 3: Funciones y Módulos

Módulo 4: Estructuras de Datos

Módulo 5: Programación Orientada a Objetos

Módulo 6: Manejo de Archivos

Módulo 7: Manejo de Errores y Excepciones

Módulo 8: Temas Avanzados

Módulo 9: Pruebas y Depuración

Módulo 10: Desarrollo Web con Python

Módulo 11: Ciencia de Datos con Python

Módulo 12: Proyecto Final

© Copyright 2026. Todos los derechos reservados