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
- Instalación en el entorno virtual
- La aplicación mínima: cinco líneas, línea a línea
@app.routees un decorador (y tú ya sabes lo que eso significa)- El servidor de desarrollo y el peligro de
debug=True - Rutas con variables:
/libro/<titulo> - Plantillas Jinja2: HTML con huecos
- Herencia de plantillas:
base.html - Archivos estáticos: un CSS mínimo
- El buscador: un formulario GET sobre
buscar_libro() - Estructura de proyecto razonable
Instalación en el entorno virtual
Como cualquier paquete desde M1: entorno virtual activado, pip install:
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 carpetastemplates/ystatic/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ódigo200 OKautomá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/<titulo>"| 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:
O añadiendo al final de app.py el bloque if __name__ == "__main__": de M3:
y ejecutando python app.py. En ambos casos verás algo como:
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/Hamletejecutaficha_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_libroyprecio_finalson los de M5–M6, intactos. La vista solo traduce entre HTTP y tu paquete. Hamlet responde9.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í, el404de 10-01 cuando el libro no existe. (En 10-03 haremos esta traducción de forma más elegante conobtener_libroy 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.argses un diccionario (M4) con los parámetros de la query string;.get()con valor por defecto evita elKeyErrorsi alguien visita/buscarsin parámetro — reflejo defensivo de M7.- De nuevo, la lógica es la de siempre:
buscar_libro()devuelveLibro | 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 exactamentetemplates/y estar junto aapp.py(por esoFlask(__name__)). Mismo remedio para estáticos constatic/.- Editas y el navegador no cambia: o no arrancaste con
--debug(sin recarga automática), o es la caché del navegador — recarga conCtrl+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 librofalla — una vista devuelve texto/HTML (o JSON, en 10-03), no unLibro. 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 apapyrus/, donde está testeado. La vista pide, el paquete resuelve, la plantilla pinta. - Olvidar el 404: si
buscar_librodevuelveNoney 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
- Introducción a Python
- Configuración del Entorno de Desarrollo
- Sintaxis de Python y Tipos de Datos Básicos
- Variables y Constantes
- Entrada y Salida Básica
- Entornos Virtuales y Gestión de Paquetes
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for y while
- Herramientas de Control de Flujo
- Comprensiones de Listas
Módulo 3: Funciones y Módulos
- Definición de Funciones
- Argumentos de Función
- Funciones Lambda
- Módulos y Paquetes
- Visión General de la Biblioteca Estándar
Módulo 4: Estructuras de Datos
Módulo 5: Programación Orientada a Objetos
Módulo 6: Manejo de Archivos
- Lectura y Escritura de Archivos
- Trabajo con Archivos CSV
- Manejo de Datos JSON
- Operaciones con Archivos y Directorios
Módulo 7: Manejo de Errores y Excepciones
- Introducción a las Excepciones
- Manejo de Excepciones
- Lanzamiento de Excepciones
- Excepciones Personalizadas
- Buenas Prácticas y Registro de Errores con logging
Módulo 8: Temas Avanzados
- Anotaciones de Tipos (type hints)
- Decoradores
- Generadores
- Administradores de Contexto
- Concurrencia: Hilos y Procesos
- Asyncio para Programación Asíncrona
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con unittest
- Pruebas con pytest
- Desarrollo Guiado por Pruebas
- Técnicas de Depuración
- Uso de pdb para Depuración
Módulo 10: Desarrollo Web con Python
- Introducción al Desarrollo Web
- Fundamentos del Framework Flask
- Construcción de APIs REST con Flask
- Introducción a Django
- Construcción de Aplicaciones Web con Django
Módulo 11: Ciencia de Datos con Python
- Introducción a la Ciencia de Datos
- NumPy para Computación Numérica
- Pandas para Manipulación de Datos
- Matplotlib para Visualización de Datos
- Introducción al Aprendizaje Automático con scikit-learn
