100 Días de Código en Python — Día 55: Rutas con variables, HTML desde Flask y el juego "Mayor o menor" en la web
Nivel: Grado décimo
Área: Tecnología e Informática
Modalidad: Tutorial práctico
Fuente: Curso "100 Days of Code: The Complete Python Pro Bootcamp" (Angela Yu, App Brewery). Contenido del Día 55 (nivel intermedio+).
25 min de lectura
15 secciones
Este tutorial adapta pedagógicamente el Día 55. Las secciones 📝 Contenido original del curso y 📝 Ejercicio original del curso provienen de las lecciones 394 a 399. La solución oficial del proyecto final está en el gist enlazado en la lección 399. El ejercicio de decoradores con argumentos se menciona al final de la lección 398, pero su enunciado no está entre los archivos suministrados; aquí se propone uno equivalente, marcado como tal. Los retos 🟢🟡🟠🔴, la sección de seguridad, el proyecto extra y la etapa 2 del proyecto integrador son creaciones de esta adaptación. Las correcciones al material original aparecen como 🛠️ Nota técnica.
Nota sobre las verificaciones: el 27 de septiembre de 2026, con Python 3.14 y Flask 3.1.3, se ejecutaron los ejemplos con el cliente de pruebas de Flask. Se comprobó: el comportamiento de los convertidores int y path, que una ruta con variable inserta HTML sin escapar si se devuelve con una f-string, que apilar decoradores propios bajo @app.route produce un AssertionError sin functools.wraps, que el decorador del curso con args[0] falla si el argumento se pasa por nombre, y que los cuatro GIF del proyecto final siguen disponibles.
Hoy se profundiza en Flask: cómo devolver HTML, cómo leer lo que el usuario escribe en la URL y cómo usar decoradores avanzados que reciben argumentos posicionales y de palabra clave.
Todo termina en un juego de adivinar un número en la web: la ruta de inicio / pide adivinar un número entre 0 y 9; si visitas /3, la página responde si es muy bajo, muy alto o correcto, con un título de color y un GIF distinto en cada caso.
Prerequisitos: Flask básico y decoradores (Día 54), HTML y CSS en línea (Días 41 a 44), clases (Días 16 a 20), *args y **kwargs (Día 27), el juego de adivinar el número (Día 12) y "Mayor o menor" (Día 14).
Reglas variables. Para capturar lo que el usuario escribe en una parte de la ruta, se encierra entre corchetes angulares. Flask pasa ese valor a la función como un argumento con el mismo nombre:
Python
123
@app.route("/username/<name>")defgreet(name):returnf"Hello there {name}!"
Visitar /username/Angela muestra "Hello there Angela!". Se puede agregar texto antes y después de la variable (/username/<name>/1).
Modo de depuración. Cada cambio en el servidor exige detenerlo y volver a ejecutarlo; si no, aparece un 404 aunque la ruta ya exista en el código. Con el modo de depuración:
Python
12
if __name__ =="__main__":
app.run(debug=True)
se activa el recargador automático: al guardar el archivo, el servidor se reinicia solo;
se activa el depurador en el navegador cuando hay un error.
El depurador en acción. La profesora provoca dos errores:
Cambia la ruta a <username> pero deja la función con el parámetro name → TypeError: greet() got an unexpected keyword argument 'username'.
Hace name + 12 → TypeError: can only concatenate str (not "int") to str. El depurador señala el archivo y la línea del error.
Al pulsar el ícono de la consola en la página de error, Flask pide el PIN que aparece en la terminal. Con él se abre una consola en ese punto del código, donde puedes escribir name y ver su valor. El PIN existe para que, si el sitio estuviera en Internet con el modo de depuración activo, un extraño no pudiera usar esa consola.
Convertidores. Por defecto la variable es un texto sin barras (string). Se puede especificar otro tipo:
Python
1234
@app.route("/username/<path:name>")# acepta barras: /username/Angela/1 → "Angela/1"@app.route("/username/<name>/<int:number>")# número entero: /username/Angela/12defgreet(name, number):returnf"Hello there {name}, you are {number} years old!"
Los convertidores más usados (verificado con Flask 3.1):
Convertidor
Acepta
Ruta de ejemplo
Visitar .../15
.../abc
.../-3
.../4.5
string (por defecto)
Texto sin /
/valor/<v>
"15" (¡texto!)
"abc"
"-3"
"4.5"
int
Enteros no negativos
/valor/<int:v>
15
404
404
404
float
Decimales con punto y no negativos
/valor/<float:v>
404
404
404
4.5
path
Texto con/
/archivo/<path:ruta>
—
—
—
—
Tres consecuencias importantes:
Sin int:, el valor es texto: "15" + 1 lanza TypeError y "15" == 15 es False.
Con int:, Flask rechaza lo que no sea un entero con un 404 antes de llamar a tu función. Es validación gratis. Si necesitas negativos: <int(signed=True):v> (verificado: /valor/-3 → -3).
floatno acepta 15 sin punto decimal (verificado). Si quieres aceptar 15 y 4.5, registra dos rutas (una int y una float) sobre la misma función, o recibe texto y conviértelo tú con float() dentro de un try.
🛠️ Nota técnica: hoy también puedes activar la depuración sin tocar el código, al ejecutar: flask --app server run --debug. Y nunca publiques un sitio con debug=True: la consola del depurador permite ejecutar código Python en el servidor, y el PIN es solo una barrera, no una garantía.
🛠️ Nota técnica (verificado): la barra final importa. Con @app.route("/username/<name>"), la URL /username/Ana/ (con barra al final) da 404.
🟢 Reto 1 — Aplicación: una ruta /cuadrado/<int:n> que devuelva el cuadrado de n.
🟡 Reto 2 — Ampliación:/saludo/<nombre>/<int:edad> que diga cuántos años le faltan para cumplir 18 (o que ya es mayor de edad).
🟠 Reto 3 — Desafío: provoca a propósito un TypeError en una ruta, abre la consola del depurador con el PIN e inspecciona tres variables. Toma captura.
🔴 Reto 4 — Pensamiento computacional: diseña las rutas (solo las URL, sin código) de una biblioteca escolar: ver un libro por código, ver los libros de un autor, ver los préstamos de un estudiante por su número de lista. ¿Qué convertidor usa cada variable y por qué?
Cuando una función devuelve un texto simple, Flask lo mete en el <body> sin más estructura. Pero Flask acepta HTML en el return:
Python
12345
@app.route("/")defhello_world():return'<h1 style="text-align: center">Hello, World!</h1>' \
'<p>This is a paragraph.</p>' \
'<img src="https://media.giphy.com/media/hvS1eKlR75hMr0l7VJ/giphy.gif" width=200>'
Puedes usar cualquier etiqueta, atributos y CSS en línea.
Para varias líneas, PyCharm corta la cadena al presionar Enter y agrega una barra invertida\ al final de la línea.
Un <img> no necesita etiqueta de cierre, pero sí src. También sirven los GIF (por ejemplo, de Giphy), y puedes ajustar su width.
Comillas: si el return usa comillas simples por fuera, los atributos van con comillas dobles por dentro, y viceversa.
📖 Tres formas de escribir HTML largo (adaptación)#
Forma
Ejemplo
Comentario
Barra invertida (la del curso)
'<h1>..</h1>' \ + salto de línea
Funciona, pero un espacio después de \ rompe el código
Paréntesis
return ('<h1>..</h1>' '<p>..</p>')
Python une las cadenas dentro de los paréntesis; más seguro
Triple comilla
return """<h1>..</h1> <p>..</p>"""
Cómodo para bloques grandes; admite ambas comillas adentro
Aun así, escribir páginas enteras dentro de Python es incómodo: no tienes el resaltado ni el autocompletado de un archivo HTML. El Día 56 moverás el HTML a plantillas.
Junta las dos lecciones anteriores: una ruta con variable y un return que se interpreta como HTML.
Python
123
@app.route("/username/<name>")defgreet(name):returnf"Hello there {name}!"
¿Qué pasa si alguien visita /username/<h1 style='color:red'>HACKEADO<h1>? Verificado con Flask 3.1, la respuesta es:
HTML
1
Hello there <h1style='color:red'>HACKEADO<h1>!
El navegador dibuja ese HTML. Con etiquetas más peligrosas, un atacante podría ejecutar JavaScript en la página de otra persona que abra un enlace preparado. Este ataque se llama XSS (Cross-Site Scripting) y está entre los más comunes en la web.
La solución: escapar. Escapar convierte los caracteres especiales en texto inofensivo (< pasa a <). Flask incluye la función escape (del paquete markupsafe, que se instala con Flask). De hecho, el propio Quickstart de Flask la usa en su ejemplo de rutas variables:
Python
12345
from markupsafe import escape
@app.route("/username/<name>")defgreet(name):returnf"Hello there {escape(name)}!"
Resultado verificado: Hello there <h1 style='color:red'>HACKEADO<h1>! El navegador lo muestra como texto; no lo ejecuta.
💡 El Día 56 usarás plantillas con render_template, que escapan automáticamente las variables. Mientras construyas HTML con f-strings, el responsable eres tú.
Envolver un texto en <b>, <em> y <u> a mano es propenso a errores tipográficos y difícil de editar. El reto: crear tres decoradores, make_bold, make_emphasis y make_underlined, que se ponen debajo de @app.route(...) y envuelven el texto que devuelve la función en la etiqueta correspondiente:
Los decoradores se aplican de abajo hacia arriba: el más cercano a la función la envuelve primero. @app.route queda arriba, así que registra la versión ya decorada.
🛠️ Nota técnica (verificado): con un solo uso funciona, pero si decoras dos rutas con make_bold, Flask se detiene al arrancar:
Texto
1
AssertionError: View function mapping is overwriting an existing endpoint function: wrapper
Flask identifica cada ruta por el nombre de la función (su endpoint). Ambas rutas le entregan una función llamada wrapper, y Flask no permite dos con el mismo nombre. La solución estándar es functools.wraps, que copia el nombre (y la documentación) de la función original al wrapper:
🟢 Reto 1: agrega make_heading que envuelva en <h1>.
🟡 Reto 2: cambia el orden de los decoradores y mira el código fuente (Ctrl + U). Explica el orden de las etiquetas.
🟠 Reto 3: escribe una sola función envolver_en(etiqueta) que fabrique decoradores: @envolver_en("b"), @envolver_en("mark"). (Pista: una función que devuelve un decorador, que devuelve un wrapper: tres niveles. Es lo mismo que hace @app.route("/").)
🔴 Reto 4: ¿make_bold sirve para una ruta con variable como greet(name)? Pruébalo, explica el error y resuélvelo con lo que aprenderás en la sección 5.
Hasta ahora los wrappers llamaban a la función sin argumentos. ¿Y si la función los necesita, o si el decorador debe revisar algo antes de llamarla?
La profesora simula un blog donde solo los usuarios que iniciaron sesión pueden publicar:
Python
123456789101112131415161718192021
classUser:def__init__(self, name):
self.name = name
self.is_logged_in =Falsedefis_authenticated_decorator(function):defwrapper(*args,**kwargs):if args[0].is_logged_in ==True:
function(args[0])return wrapper
@is_authenticated_decoratordefcreate_blog_post(user):print(f"This is {user.name}'s new blog post.")
new_user = User("angela")
new_user.is_logged_in =True
create_blog_post(new_user)
Por qué *args y **kwargs: sin ellos, create_blog_post(new_user) falla con "wrapper() takes 0 positional arguments but 1 was given": la función que realmente se llama es el wrapper, y no aceptaba argumentos. Con *args (argumentos posicionales ilimitados) y **kwargs (de palabra clave ilimitados), el wrapper recibe lo que le pasen. args[0] es el primer argumento posicional: el usuario.
Si is_logged_in es False, no se imprime nada; al cambiarlo a True, se publica.
Mejora 1: reenviar todo. El código del curso llama function(args[0]), que solo sirve para funciones con un argumento. La forma general reenvía todo lo que llegó y devuelve el resultado:
Mejora 2: no depender de la posición. Verificado: si alguien llama create_blog_post(user=new_user) (por nombre), args está vacío y args[0] lanza IndexError: tuple index out of range. Una versión más robusta busca el usuario en los dos lugares:
Python
12345678910
import functools
defis_authenticated_decorator(function):@functools.wraps(function)defwrapper(*args,**kwargs):
user = args[0]if args else kwargs.get("user")if user isnotNoneand user.is_logged_in:return function(*args,**kwargs)print("Acceso denegado: inicia sesión primero.")return wrapper
if user.is_logged_in: es más claro que == True.
Informar "acceso denegado" es mejor que no hacer nada en silencio.
💡 Este patrón es exactamente el de la vida real: en el Día 68 del curso usarás @login_required, un decorador que protege rutas de Flask para que solo las vean usuarios autenticados.
📝 Ejercicio de práctica: decorador que registra llamadas#
(El video anuncia "un ejercicio de codificación para probar esta nueva forma avanzada de decoradores". Su enunciado no está entre los archivos suministrados; este es un ejercicio equivalente propuesto por esta adaptación.)
Crea logging_decorator que, al llamar a la función decorada, imprima su nombre, los argumentos con que se llamó y el valor que devolvió:
Python
12345678
@logging_decoratordefa_function(a, b, c):return a * b * c
a_function(1,2,3)# Salida esperada (el formato exacto es tuyo):# Llamaste a_function(1, 2, 3)# Devolvió: 6
Condiciones: debe funcionar con cualquier número de argumentos, debe devolver el resultado (no solo imprimirlo) y debe usar functools.wraps.
🟢 Reto 1: agrega a User un atributo es_docente y crea @solo_docentes para una función calificar(user, nota).
🟡 Reto 2: aplica logging_decorator a una función con argumentos por nombre (precio(valor, descuento=0.1)) y muestra también los kwargs.
🟠 Reto 3: reescribe make_bold (sección 4) con *args y **kwargs para que funcione con la ruta greet(name).
🔴 Reto 4 — Pensamiento computacional: termina el decorador @reintentar que diseñaste el Día 54: que reenvíe los argumentos y reintente hasta 3 veces si hay una excepción. Pruébalo con una función que falle al azar el 50 % de las veces (random.random() < 0.5).
Es el juego de adivinar del Día 14, pero ahora como sitio web.
Crea un proyecto higher-lower con un archivo server.py.
Una aplicación Flask cuya ruta de inicio muestre un <h1> que diga "Guess a number between 0 and 9" y un GIF de tu elección de giphy.com (o el que propone el curso: https://media.giphy.com/media/3o7aCSPqXE5C6T8tBC/giphy.gif).
Genera un número aleatorio entre 0 y 9 (u otro rango que elijas).
Crea una ruta que detecte el número que el usuario escribe en la URL (por ejemplo URL/3 o URL/9) y lo compare con el número aleatorio: muy bajo, muy alto o correcto. Intenta que el <h1> tenga un color distinto en cada caso. Por ejemplo, si el número fuera 5: 3 es muy bajo, 7 es muy alto y 5 es correcto.
GIF del curso (verificados, siguen disponibles): muy alto …/3o6ZtaO9BZHcOjmErm/giphy.gif, muy bajo …/jD4DwBtqPXRXa/giphy.gif, correcto …/4T7e4DmcrP9du/giphy.gif (todos en https://media.giphy.com/media/). "Es mucho más divertido buscar los tuyos".
El juego de consola del Día 14 solo lo podía jugar quien estaba frente al computador. Como sitio web, cualquier persona en la red del salón puede jugar desde su celular escribiendo una URL.
¿Qué información tenemos? El número que el usuario escribe en la URL.
¿Dónde se genera el número secreto: dentro de la función de la ruta o fuera? ¿Qué pasaría en cada caso? (Pista: si se genera dentro, ¡cambia en cada visita!)
¿Qué convertidor usarás para que /abc no llegue a tu función?
¿Qué salidas hay? Tres páginas, cada una con color y GIF propios.
Comprender. 2. Entrada: el número en la URL. 3. Proceso: comparar con el secreto. 4. Salidas: tres páginas. 5. Dividir: ruta de inicio, ruta de intento. 6. Diseñar la tabla de colores y GIF. 7. Implementar. 8. Probar. 9. Corregir. 10. Mejorar.
(Fija temporalmente el número secreto en 5 para probar.)
URL
Resultado esperado
/
"Guess a number between 0 and 9" y el GIF
/3
Muy bajo, con su color y GIF
/7
Muy alto, con su color y GIF
/5
¡Correcto!
/15
Mensaje de fuera de rango
/abc y /-1
404 (el convertidor int los rechaza, verificado)
🛠️ Nota técnica: con el modo de depuración activo, cada vez que guardas el archivo el servidor se reinicia y el número secreto cambia. No es un error: el módulo se vuelve a ejecutar. Además, el número es el mismo para todos los jugadores de la red: el servidor tiene una sola variable. Para que cada jugador tenga su propio número harían falta cookies o sesiones (Día 68 del curso).
Encuentra cuatro problemas: falta int: (comparar texto con número lanza TypeError), el número secreto se genera en cada visita, las comillas dobles dentro y fuera dan SyntaxError, y falta el caso correcto (la función devolvería None y Flask mostraría un error).
Rutas para al menos cuatro operaciones: /sumar/<a>/<b>, /restar/..., /multiplicar/..., /dividir/..., con el convertidor adecuado (decide si int o float, y cómo aceptar negativos).
Un diccionario de funciones (Día 54, sección 4) en lugar de cuatro funciones de ruta repetidas: una sola ruta /<operacion>/<a>/<b>.
La división entre cero muestra un mensaje amable, no el depurador.
Un decorador @registrar (con *args, **kwargs y functools.wraps) que guarde cada operación en historial.txt.
Una ruta /historial que muestre las últimas 10 operaciones, con escape sobre lo que se lee del archivo.
¿/dividir/7/2 debe dar 3.5 o 3? ¿Y cómo aceptas /sumar/2.5/3, si el convertidor float rechaza 3 y el int rechaza 2.5? (Revisa la tabla de la sección 1.)
¿Qué respuesta das a /potencia/2/3 si no implementaste potencia: un 404 o un mensaje propio?
/dividir/1/0 → mensaje "No se puede dividir entre cero".
/restar/5/-3 → 8 (solo si aceptas negativos).
Tras 3 operaciones, /historial muestra 3 líneas.
10
8. 🏗️ Proyecto integrador del bloque Flask — Etapa 2#
(Proyecto creado para esta adaptación. Descripción completa en el tutorial del Día 58.)
El Portal del Grupo — Etapa 2 (hoy):
Una ruta /integrante/<nombre> que muestre la ficha de un integrante (datos ficticios en un diccionario) o un mensaje "No encontrado" si el nombre no existe. Usa escape.
Una ruta /actividad/<int:numero> que muestre la actividad número n de una lista.
Los decoradores de estilo de la sección 4 confunctools.wraps, aplicados a al menos dos rutas.
Ejecuta con --debug y documenta en cuaderno_errores.md un error que hayas encontrado con el depurador.
defmake_bold(f):defw():return"<b>"+ f()+"</b>"return w
defmake_underlined(f):defw():return"<u>"+ f()+"</u>"return w
@make_underlined@make_bolddeftexto():return"Hola"print(texto())
Cambia el proyecto "Mayor o menor" para que el rango sea de 1 a 100 y el mensaje diga "muy bajo" o "un poco bajo" según la distancia al número secreto (más o menos de 10).
Ciclo de trabajo: formula el problema → inténtalo tú → consulta a la IA → lee y entiende → prueba → compara con la documentación oficial → modifica → explica con tus palabras.
Actividad 1 — ¿Es seguro? Pide a una IA una ruta de Flask que salude al usuario por su nombre desde la URL. ¿Usa escape? Pruébala con <b>hola</b> en la URL. Si no escapa, corrígela y explica a la IA el problema.
Actividad 2 — Comprobar una afirmación. Pregunta a una IA si <int:n> acepta números negativos en Flask. Verifícalo tú (/ruta/-3) y consulta la sección "Variable Rules" y la de convertidores de la documentación de Flask/Werkzeug.
Actividad 3 — El error del endpoint. Muestra a una IA el AssertionError: View function mapping is overwriting an existing endpoint function: wrapper y pide una explicación. Compárala con la nota técnica de la sección 4 y con la documentación de functools.wraps.
13
11. 🛠️ Correcciones técnicas al material original#
Archivo
Afirmación o comportamiento del curso
Observación
Tratamiento
395, 396
return f"Hello there {name}!" con una variable de la URL
Inserta HTML del usuario sin escapar: vulnerable a XSS (verificado)
Sección 3 con markupsafe.escape
395
Activar debug=True
Correcto en desarrollo; nunca en producción. Hoy también existe flask run --debug
Nota técnica de la sección 1
397
Apilar make_bold, make_emphasis y make_underlined bajo @app.route
Si el mismo decorador se usa en dos rutas, Flask lanza AssertionError por el endpointwrapper (verificado)
Nota técnica de la sección 4 con functools.wraps
398
if args[0].is_logged_in == True: function(args[0])
Falla con IndexError si el usuario se pasa por nombre (verificado); solo sirve para funciones de un argumento; no devuelve el resultado
Sección 5: versión con *args, **kwargs, return y wraps
398
Ejercicio de codificación de decoradores avanzados
El enunciado no está entre los archivos suministrados
Ejercicio equivalente en la sección 5, marcado como propio
399
Número aleatorio del juego
Con el recargador cambia al guardar y es compartido por todos los jugadores
Nota técnica de la sección 6
396
Cortar cadenas largas con \
Funciona, pero es frágil (un espacio después de \ rompe el código)
Objetivos: HTML, lectura de la URL, decoradores avanzados, juego de adivinar en la web
394
Sección 0
—
—
Sección 6
Rutas /bye y /username/<name>, reglas variables, reiniciar el servidor, modo de depuración, recargador, depurador y PIN, errores de nombre y de tipo, texto antes y después de la variable, convertidores path e int, varias variables
395
Sección 1
Ejemplos de rutas variables
Retos 1–4 de la sección 1; tabla de convertidores
Secciones 6, 7 y 8
Devolver HTML, CSS en línea, varios elementos, \ para cortar líneas, <img> con GIF, ancho, comillas simples y dobles
396
Sección 2
Página con título, párrafo e imagen
Tres formas de escribir HTML largo
Sección 6
(Consecuencia de 395 + 396; no se trata en el curso)
—
Sección 3
—
XSS y escape; retos 1–3
Secciones 7 y 8
Reto de decoradores make_bold, make_emphasis, make_underlined bajo @app.route
397
Sección 4
Reto de los tres decoradores
Retos 1–4 de la sección 4; functools.wraps
Sección 8
Clase User, is_logged_in, is_authenticated_decorator, *args y **kwargs, args[0], ejercicio de decoradores avanzados
398
Sección 5
Decorador de autenticación; ejercicio (equivalente propuesto)
Retos 1–4 de la sección 5
Sección 7
Proyecto "Higher or Lower URLs": server.py, ruta de inicio con <h1> y GIF, número aleatorio, ruta con el número, colores y GIF por caso
"Mayor o menor" funcionando, con capturas de las cuatro páginas (inicio, bajo, alto, correcto) y de un 404.
La captura de la consola del depurador con el PIN (Reto 3 de la sección 1).
La comparación "sin escape" y "con escape" (código fuente de la página).
Los tres decoradores de estilo con functools.wraps aplicados a dos rutas.
logging_decorator funcionando con funciones de distinto número de argumentos.
La calculadora por URL (proyecto extra) con su historial.
La etapa 2 del Portal del Grupo.
Cuaderno de errores: nombre de la variable distinto del parámetro, texto contra número en la URL, barra final, comillas mezcladas, \ con espacio, HTML sin escapar, wrapper sin return, endpointwrapper repetido, args[0] con argumentos por nombre, número secreto dentro de la función.