Módulo 2 · Lección 3 de 28
Entrada robusta por teclado
Funciones reutilizables para pedir textos, números, fechas y confirmaciones sin que el programa muera cuando alguien escribe cualquier cosa.
input() devuelve siempre una cadena, y en cuanto la conviertes empiezan los problemas. Quien usa tu programa va a escribir treinta y cuatro donde esperabas un número, va a pulsar Intro sin escribir nada, va a poner una coma donde tú querías un punto y va a escribir una fecha al estilo español. Ninguna de esas cosas es un error suyo: es un fallo de tu programa si no las aguanta.
La solución no es llenar el código de try/except por todas partes. Es escribir un puñado de funciones de lectura, una vez, y usarlas siempre. Eso es el contenido de esta lección: al terminar tendrás un módulo entradas.py que vas a reutilizar en el resto del módulo y, con suerte, en el resto de tu vida.
Lo que pasa hoy#
Este programa de dos líneas tiene tres formas distintas de romperse:
edad = int(input("Tu edad: "))
print(f"El año que viene tendrás {edad + 1}")Si escribes letras:
Tu edad: treinta y cuatro
Traceback (most recent call last):
File "/Users/erwin/curso-python/edad.py", line 1, in <module>
edad = int(input("Tu edad: "))
ValueError: invalid literal for int() with base 10: 'treinta y cuatro'Si pulsas Intro sin escribir nada:
Tu edad:
Traceback (most recent call last):
File "/Users/erwin/curso-python/edad.py", line 1, in <module>
edad = int(input("Tu edad: "))
ValueError: invalid literal for int() with base 10: ''Y si pulsas Ctrl + D, que significa "se ha terminado la entrada":
Tu edad:
Traceback (most recent call last):
File "/Users/erwin/curso-python/edad.py", line 1, in <module>
edad = int(input("Tu edad: "))
~~~~~^^^^^^^^^^^^^
EOFError: EOF when reading a lineTres pantallazos de traceback por un error de mecanografía. Y eso es solo la validación de tipo: int("-5") funciona perfectamente y una edad de menos cinco años no tiene sentido.
El patrón de las tres piezas#
Toda función de lectura robusta tiene la misma forma: un bucle infinito, un try que intenta convertir y un except que explica el problema y deja que el bucle dé otra vuelta.
def pedir_entero(etiqueta: str) -> int:
"""Insiste hasta que escriban un número entero."""
while True:
try:
return int(input(f"{etiqueta}: "))
except ValueError:
print(" Eso no es un número entero.")Unidades: abc
Eso no es un número entero.
Unidades:
Eso no es un número entero.
Unidades: 7
unidades=7Fíjate en la estructura: el return está dentro del try. Si la conversión sale bien, la función termina ahí mismo y el bucle no vuelve a dar vueltas; si falla, el except imprime y el while True reintenta. No hace falta ninguna variable de control ni ningún break.
Un texto de la pregunta consistente#
Antes de escribir seis funciones conviene decidir cómo se ven las preguntas, porque todas van a necesitar lo mismo: una etiqueta, a veces una pista del formato y a veces un valor por defecto. Una función pequeña lo compone:
def _pregunta(etiqueta: str, pista: str = "", por_defecto: object = None) -> str:
"""Compone el texto de la pregunta: etiqueta, pista y valor por defecto."""
texto = etiqueta
if pista:
texto += f" ({pista})"
if por_defecto is not None:
texto += f" [{por_defecto}]"
return texto + ": "Así, quien llama escribe pedir_entero("Unidades", minimo=1, maximo=10) y en la pantalla aparece Unidades (1-10):, sin tener que repetir el rango a mano en el texto. El guion bajo del nombre avisa de que es maquinaria interna del módulo.
La convención del paréntesis y del corchete no es un invento: es la de las herramientas de Unix. El paréntesis indica lo que se espera y el corchete, lo que va a pasar si pulsas Intro a secas.
pedir_texto#
La más sencilla, y la que más se usa. Tres comportamientos en una función: insistir si está vacía, aceptar el vacío o devolver un valor por defecto.
def pedir_texto(
etiqueta: str,
por_defecto: str | None = None,
permitir_vacio: bool = False,
) -> str:
"""Lee una cadena. Insiste mientras esté vacía, salvo que se permita."""
mensaje = _pregunta(etiqueta, por_defecto=por_defecto)
while True:
respuesta = input(mensaje).strip()
if respuesta:
return respuesta
if por_defecto is not None:
return por_defecto
if permitir_vacio:
return ""
print(" No puede quedar en blanco.")El .strip() es obligatorio y no un adorno: un espacio de más al copiar y pegar no debería convertir una respuesta válida en un dato con basura. Y el orden de los tres if no es casual: primero se acepta lo que tiene contenido, después se aplica el valor por defecto, y solo al final se decide si el vacío vale o hay que insistir.
pedir_entero, con mínimo y máximo#
Aquí se juntan las dos validaciones: que sea un entero y que esté en el rango razonable.
def pedir_entero(
etiqueta: str,
minimo: int | None = None,
maximo: int | None = None,
por_defecto: int | None = None,
) -> int:
"""Lee un número entero dentro del rango indicado."""
if minimo is not None and maximo is not None:
pista = f"{minimo}-{maximo}"
elif minimo is not None:
pista = f"{minimo} o más"
elif maximo is not None:
pista = f"{maximo} o menos"
else:
pista = ""
mensaje = _pregunta(etiqueta, pista, por_defecto)
while True:
escrito = input(mensaje).strip()
if not escrito and por_defecto is not None:
return por_defecto
try:
numero = int(escrito)
except ValueError:
print(f" '{escrito}' no es un número entero.")
continue
if minimo is not None and numero < minimo:
print(f" Tiene que ser {minimo} o más.")
continue
if maximo is not None and numero > maximo:
print(f" Tiene que ser {maximo} o menos.")
continue
return numeroCon esto, el programa del principio de la lección se vuelve insensible a los despistes:
edad = pedir_entero("Tu edad", minimo=0, maximo=120)
print(f"El año que viene tendrás {edad + 1}")Tu edad (0-120): treinta y cuatro
'treinta y cuatro' no es un número entero.
Tu edad (0-120): 34,5
'34,5' no es un número entero.
Tu edad (0-120):
'' no es un número entero.
Tu edad (0-120): 34
El año que viene tendrás 35Dos decisiones que merecen explicación:
- Los límites se comprueban con
continue, no conraise. Un valor fuera de rango no es un fallo del programa: es una respuesta que hay que corregir. Elcontinuemanda al bucle a preguntar otra vez, exactamente igual que el error de conversión. minimoymaximovalenNonepor defecto, y se comparan conis not Noney no con unif minimo:. La diferencia importa:if minimo:daría falso conminimo=0, y un mínimo de cero es lo más habitual del mundo.
pedir_decimal y la coma española#
Un decimal se lee igual, con dos diferencias: se convierte con float y se acepta la coma, porque en España es lo que la gente escribe.
def pedir_decimal(
etiqueta: str,
minimo: float | None = None,
maximo: float | None = None,
) -> float:
"""Lee un número con decimales y acepta la coma como separador."""
mensaje = _pregunta(etiqueta)
while True:
escrito = input(mensaje).strip().replace(",", ".")
try:
numero = float(escrito)
except ValueError:
print(f" '{escrito}' no es un número.")
continue
if minimo is not None and numero < minimo:
print(f" Tiene que ser {minimo} o más.")
continue
if maximo is not None and numero > maximo:
print(f" Tiene que ser {maximo} o menos.")
continue
return numeroEse .replace(",", ".") de una línea evita un montón de frustración:
precio = pedir_decimal("Precio en euros", minimo=0)
print(f"{precio=}")
print(f"Tres unidades: {precio * 3:.2f} €")Precio en euros: doce euros
'doce euros' no es un número.
Precio en euros: -3
Tiene que ser 0 o más.
Precio en euros: 12,50
precio=12.5
Tres unidades: 37.50 €pedir_fecha#
date.fromisoformat hace todo el trabajo de validación, incluidos los días imposibles como el 31 de febrero. Lo único que hay que hacer es capturar su ValueError y explicar el formato:
def pedir_fecha(etiqueta: str, por_defecto: date | None = None) -> date:
"""Lee una fecha escrita como AAAA-MM-DD."""
mensaje = _pregunta(etiqueta, "AAAA-MM-DD", por_defecto)
while True:
escrito = input(mensaje).strip()
if not escrito and por_defecto is not None:
return por_defecto
try:
return date.fromisoformat(escrito)
except ValueError as error:
print(f" Fecha no válida: {error}.")Aquí el mensaje del except reaprovecha el texto de la excepción, que es sorprendentemente claro, como se ve en el ejemplo grande de más abajo. La función devuelve un objeto date, no una cadena, así que quien la llame puede restar fechas o compararlas sin más conversiones.
pedir_confirmacion#
Preguntar "¿seguro?" parece trivial hasta que te pones. Hay que aceptar s, si, sí y n y no, no distinguir mayúsculas, y dejar que Intro signifique la respuesta más probable.
AFIRMATIVAS = {"s", "si", "sí", "y", "yes"}
NEGATIVAS = {"n", "no"}
def pedir_confirmacion(etiqueta: str, por_defecto: bool | None = None) -> bool:
"""Lee un sí o un no. Con por_defecto, Intro ya vale como respuesta."""
if por_defecto is True:
pista = "S/n"
elif por_defecto is False:
pista = "s/N"
else:
pista = "s/n"
mensaje = _pregunta(etiqueta, pista)
while True:
respuesta = input(mensaje).strip().lower()
if not respuesta and por_defecto is not None:
return por_defecto
if respuesta in AFIRMATIVAS:
return True
if respuesta in NEGATIVAS:
return False
print(" Responde s o n.")print(pedir_confirmacion("¿Continuar?", por_defecto=True))
print(pedir_confirmacion("¿Borro el fichero?"))
print(pedir_confirmacion("¿Guardar los cambios?", por_defecto=False))
print(pedir_confirmacion("¿Seguro?"))¿Continuar? (S/n):
True
¿Borro el fichero? (s/n): s
True
¿Guardar los cambios? (s/N):
False
¿Seguro? (s/n): no
FalseTres detalles con su razón:
- Los conjuntos, no listas. La comprobación
insobre un conjunto es inmediata y el conjunto deja claro que no hay orden ni repeticiones. Además se lee mejor: añadir"vale"aAFIRMATIVASes evidente. - La mayúscula de la pista indica el valor por defecto.
(S/n)significa "si pulsas Intro, digo sí". Es la convención de todo Unix y quien use tu programa la entenderá sin explicaciones. - Sin
por_defecto, Intro no vale. Para una pregunta peligrosa, como borrar un fichero, no hay respuesta por defecto y hay que escribir algo. Eso es deliberado: la tecla Intro es la que más se pulsa por inercia.
pedir_opcion#
La variante de elegir entre unas pocas posibilidades, que verás en la lección de menús:
def pedir_opcion(etiqueta: str, validas: list[str]) -> str:
"""Lee una de las opciones dadas, sin distinguir mayúsculas."""
permitidas = {opcion.lower(): opcion for opcion in validas}
mensaje = _pregunta(etiqueta, "/".join(validas))
while True:
respuesta = input(mensaje).strip().lower()
if respuesta in permitidas:
return permitidas[respuesta]
print(f" Elige una de estas: {', '.join(validas)}.")color = pedir_opcion("Color", ["rojo", "verde", "azul"])
print(f"{color=}")Color (rojo/verde/azul): roja
Elige una de estas: rojo, verde, azul.
Color (rojo/verde/azul): verde
color='verde'El diccionario por comprensión asocia la versión en minúsculas con la original. Así quien escriba VERDE obtiene "verde", con la grafía exacta que espera el resto del programa, y no la que haya teclado.
Contraseñas sin eco#
Si lees una contraseña con input(), aparece escrita en la pantalla y se queda ahí, visible para quien pase por detrás y para quien mire el historial de la sesión. getpass, de la biblioteca estándar, lee sin mostrar nada:
from getpass import getpass
clave = getpass("Contraseña: ")
print(f"He leído {len(clave)} caracteres")Contraseña:
He leído 7 caracteresMientras escribes no se ve nada: ni asteriscos ni puntos. Es lo normal en Unix y desconcierta la primera vez, pero está bien: no se filtra ni la longitud.
Con el patrón de siempre se construye una versión que además pide la contraseña dos veces:
def pedir_contrasena(etiqueta: str = "Contraseña", minimo: int = 8) -> str:
"""Lee una contraseña sin mostrarla y la pide dos veces."""
while True:
primera = getpass(_pregunta(etiqueta, f"{minimo} caracteres o más"))
if len(primera) < minimo:
print(f" Demasiado corta: {len(primera)} caracteres.")
continue
segunda = getpass(_pregunta("Repítela"))
if primera != segunda:
print(" No coinciden. Otra vez.")
continue
return primeraCtrl+D y Ctrl+C#
Quedan dos teclas que no escriben texto sino que interrumpen la lectura, y conviene saber qué lanza cada una:
| Tecla | Significa | Excepción |
|---|---|---|
| Ctrl + D | Se ha terminado la entrada | EOFError |
| Ctrl + C | Cancela lo que estés haciendo | KeyboardInterrupt |
Las dos atraviesan tu bucle while True sin inmutarse, porque no son ValueError. Y eso es lo correcto: si alguien quiere salirse, hay que dejarle. Lo que no hay que hacer es enseñarle un traceback, así que se capturan arriba, en el main, y no dentro de cada función de lectura:
def main() -> None:
try:
nombre = pedir_texto("Nombre")
except (EOFError, KeyboardInterrupt):
print("\nCancelado.")
return
print(f"Hola, {nombre}.")Nombre:
Cancelado.Ese \n del principio del mensaje no está de adorno: cuando cancelas, el cursor está al final de la línea de la pregunta, y sin el salto el "Cancelado" saldría pegado a ella.
El módulo completo#
Junta todo esto en un fichero al lado de tus programas y ya no vuelves a escribir un try de conversión en la vida:
"""Funciones para leer datos por teclado sin que el programa se rompa."""
from datetime import date
from getpass import getpass
AFIRMATIVAS = {"s", "si", "sí", "y", "yes"}
NEGATIVAS = {"n", "no"}
def _pregunta(etiqueta: str, pista: str = "", por_defecto: object = None) -> str:
"""Compone el texto de la pregunta: etiqueta, pista y valor por defecto."""
texto = etiqueta
if pista:
texto += f" ({pista})"
if por_defecto is not None:
texto += f" [{por_defecto}]"
return texto + ": "
def pedir_texto(
etiqueta: str,
por_defecto: str | None = None,
permitir_vacio: bool = False,
) -> str:
"""Lee una cadena. Insiste mientras esté vacía, salvo que se permita."""
mensaje = _pregunta(etiqueta, por_defecto=por_defecto)
while True:
respuesta = input(mensaje).strip()
if respuesta:
return respuesta
if por_defecto is not None:
return por_defecto
if permitir_vacio:
return ""
print(" No puede quedar en blanco.")
def pedir_entero(
etiqueta: str,
minimo: int | None = None,
maximo: int | None = None,
por_defecto: int | None = None,
) -> int:
"""Lee un número entero dentro del rango indicado."""
if minimo is not None and maximo is not None:
pista = f"{minimo}-{maximo}"
elif minimo is not None:
pista = f"{minimo} o más"
elif maximo is not None:
pista = f"{maximo} o menos"
else:
pista = ""
mensaje = _pregunta(etiqueta, pista, por_defecto)
while True:
escrito = input(mensaje).strip()
if not escrito and por_defecto is not None:
return por_defecto
try:
numero = int(escrito)
except ValueError:
print(f" '{escrito}' no es un número entero.")
continue
if minimo is not None and numero < minimo:
print(f" Tiene que ser {minimo} o más.")
continue
if maximo is not None and numero > maximo:
print(f" Tiene que ser {maximo} o menos.")
continue
return numero
def pedir_decimal(
etiqueta: str,
minimo: float | None = None,
maximo: float | None = None,
) -> float:
"""Lee un número con decimales y acepta la coma como separador."""
mensaje = _pregunta(etiqueta)
while True:
escrito = input(mensaje).strip().replace(",", ".")
try:
numero = float(escrito)
except ValueError:
print(f" '{escrito}' no es un número.")
continue
if minimo is not None and numero < minimo:
print(f" Tiene que ser {minimo} o más.")
continue
if maximo is not None and numero > maximo:
print(f" Tiene que ser {maximo} o menos.")
continue
return numero
def pedir_fecha(etiqueta: str, por_defecto: date | None = None) -> date:
"""Lee una fecha escrita como AAAA-MM-DD."""
mensaje = _pregunta(etiqueta, "AAAA-MM-DD", por_defecto)
while True:
escrito = input(mensaje).strip()
if not escrito and por_defecto is not None:
return por_defecto
try:
return date.fromisoformat(escrito)
except ValueError as error:
print(f" Fecha no válida: {error}.")
def pedir_confirmacion(etiqueta: str, por_defecto: bool | None = None) -> bool:
"""Lee un sí o un no. Con por_defecto, Intro ya vale como respuesta."""
if por_defecto is True:
pista = "S/n"
elif por_defecto is False:
pista = "s/N"
else:
pista = "s/n"
mensaje = _pregunta(etiqueta, pista)
while True:
respuesta = input(mensaje).strip().lower()
if not respuesta and por_defecto is not None:
return por_defecto
if respuesta in AFIRMATIVAS:
return True
if respuesta in NEGATIVAS:
return False
print(" Responde s o n.")
def pedir_opcion(etiqueta: str, validas: list[str]) -> str:
"""Lee una de las opciones dadas, sin distinguir mayúsculas."""
permitidas = {opcion.lower(): opcion for opcion in validas}
mensaje = _pregunta(etiqueta, "/".join(validas))
while True:
respuesta = input(mensaje).strip().lower()
if respuesta in permitidas:
return permitidas[respuesta]
print(f" Elige una de estas: {', '.join(validas)}.")
def pedir_contrasena(etiqueta: str = "Contraseña", minimo: int = 8) -> str:
"""Lee una contraseña sin mostrarla y la pide dos veces."""
while True:
primera = getpass(_pregunta(etiqueta, f"{minimo} caracteres o más"))
if len(primera) < minimo:
print(f" Demasiado corta: {len(primera)} caracteres.")
continue
segunda = getpass(_pregunta("Repítela"))
if primera != segunda:
print(" No coinciden. Otra vez.")
continue
return primeraY así se usa, con el módulo al lado del programa:
from datetime import date
from entradas import (
pedir_confirmacion,
pedir_entero,
pedir_fecha,
pedir_texto,
)
nombre = pedir_texto("Nombre")
ciudad = pedir_texto("Ciudad", por_defecto="Madrid")
cantidad = pedir_entero("Unidades", minimo=1, maximo=10)
vence = pedir_fecha("Vence el", por_defecto=date(2026, 12, 31))
urgente = pedir_confirmacion("¿Es urgente?", por_defecto=False)
print()
print(f"{nombre=}")
print(f"{ciudad=}")
print(f"{cantidad=}")
print(f"{vence=}")
print(f"{urgente=}")Una sesión con todos los despistes posibles:
Nombre:
No puede quedar en blanco.
Nombre: Erwin
Ciudad [Madrid]:
Unidades (1-10): abc
'abc' no es un número entero.
Unidades (1-10): 0
Tiene que ser 1 o más.
Unidades (1-10): 3
Vence el (AAAA-MM-DD) [2026-12-31]: 19/09/2026
Fecha no válida: Invalid isoformat string: '19/09/2026'.
Vence el (AAAA-MM-DD) [2026-12-31]: 2026-13-01
Fecha no válida: month must be in 1..12, not 13.
Vence el (AAAA-MM-DD) [2026-12-31]:
¿Es urgente? (s/N): SÍ
nombre='Erwin'
ciudad='Madrid'
cantidad=3
vence=datetime.date(2026, 12, 31)
urgente=TrueVeintiuna líneas de programa, ni un try a la vista y nada de lo que se ha escrito lo ha tumbado. Los espacios de Erwin han desaparecido, la ciudad ha salido del valor por defecto, la fecha con barras se ha rechazado con una explicación y el SÍ en mayúsculas con acento se ha entendido.