Saltar al contenido
proyecto-agenda.py · devschool

Proyecto en Python: una agenda de contactos

Lección 31 de 31 · 14 min de lectura · Actualizado el

En esta lección
  1. Qué vamos a construir
  2. Plan: tres capas
  3. Paso 1: el contacto como diccionario
  4. Paso 2: la clase Agenda
  5. Paso 3: guardar y cargar con JSON
  6. Paso 4: la lógica de las opciones, sin input()
  7. Paso 5: el menú con input()
  8. Código final completo
  9. Ideas para ampliar el proyecto
  10. Errores frecuentes
  11. Resumen

Vamos a construir una agenda de contactos que funciona en la consola: añade personas con su teléfono y su correo, las busca, las lista, las elimina y guarda todo en un fichero JSON para que los datos sigan ahí al volver a abrir el programa. Usarás casi todo lo aprendido hasta ahora: funciones, diccionarios, clases, excepciones y ficheros y JSON.

El proyecto tiene una decisión de diseño importante: separamos la lógica del menú. Toda la lógica (crear, buscar, guardar) vive en funciones y en una clase que no usan input(), así puedes probarlas sin escribir nada a mano y los bloques se ejecutan directamente aquí, en la web. El menú interactivo, con input(), va aparte en un bloque marcado, para ejecutarlo en tu ordenador.

Nota: input() no funciona en los bloques ejecutables de la web. Los dos bloques que lo usan están marcados con un comentario NO EJECUTAR EN LA WEB; cópialos a un archivo agenda.py y ejecútalo con python agenda.py.

Qué vamos a construir

Un menú como este:

=== AGENDA ===
1) Añadir contacto
2) Buscar
3) Ver todos
4) Eliminar
0) Salir

Con estas reglas:

  • Un contacto tiene nombre, teléfono (9 dígitos) y email (opcional).
  • No puede haber dos contactos con el mismo nombre (sin importar mayúsculas).
  • La búsqueda encuentra el texto en cualquier parte del nombre.
  • Los cambios se guardan en agenda.json y se recuperan al arrancar. Si el fichero no existe o está dañado, se empieza con una agenda vacía.

Plan: tres capas

CapaQué contiene¿Usa input()?
DatosUn contacto es un diccionario; funciones para crearlo y mostrarloNo
LógicaLa clase Agenda y las funciones de guardar y cargarNo
InterfazEl menú que pregunta al usuarioSí

Programar así tiene una ventaja enorme: la lógica se prueba sola (y en el futuro con pruebas automáticas), y podrías cambiar la consola por una web o una app sin tocar la lógica.

Paso 1: el contacto como diccionario

Un contacto son datos con nombre: un diccionario es lo natural.

contacto = {"nombre": "Lucía Gómez", "telefono": "600111222", "email": "lucia@correo.es"}
print(contacto["nombre"])        # Lucía Gómez
print(contacto.get("email", "")) # lucia@correo.es

Para no repetir diccionarios a mano (y para validar), creamos una función:

def nuevo_contacto(nombre, telefono, email=""):
    """Crea un contacto (diccionario) validando los datos."""
    nombre = nombre.strip()
    telefono = telefono.replace(" ", "")
    if not nombre:
        raise ValueError("El nombre no puede estar vacío")
    if not telefono.isdigit() or len(telefono) != 9:
        raise ValueError("El teléfono debe tener 9 dígitos")
    return {"nombre": nombre, "telefono": telefono, "email": email.strip().lower()}


def formatear(contacto):
    """Devuelve el contacto como una línea de texto."""
    linea = f"{contacto['nombre']:<14} {contacto['telefono']}"
    if contacto["email"]:
        linea += f"  <{contacto['email']}>"
    return linea

Fíjate en lo que hace nuevo_contacto: limpia los espacios, quita los espacios del teléfono (para aceptar “611 333 444”), pasa el correo a minúsculas y lanza ValueError si algo no cuadra. Quien la llame decidirá qué hacer con el error. {contacto['nombre']:<14} reserva 14 caracteres alineados a la izquierda, para que los teléfonos salgan en columna.

Pruébalas:

c = nuevo_contacto("  Íñigo Ruiz ", "611 333 444", "INIGO@Correo.es")
print(c)
# {'nombre': 'Íñigo Ruiz', 'telefono': '611333444', 'email': 'inigo@correo.es'}
print(formatear(c))
# Íñigo Ruiz     611333444  <inigo@correo.es>

try:
    nuevo_contacto("Pepe", "12ab")
except ValueError as error:
    print("Error:", error)
# Error: El teléfono debe tener 9 dígitos

Paso 2: la clase Agenda

Ahora necesitamos algo que guarde la lista de contactos y ofrezca operaciones sobre ella. Una clase agrupa datos y comportamiento:

class Agenda:
    """Colección de contactos en memoria."""

    def __init__(self, contactos=None):
        self.contactos = contactos if contactos is not None else []

    def __len__(self):
        return len(self.contactos)

    def _buscar_exacto(self, nombre):
        for c in self.contactos:
            if c["nombre"].lower() == nombre.strip().lower():
                return c
        return None

    def agregar(self, contacto):
        if self._buscar_exacto(contacto["nombre"]):
            raise ValueError(f"Ya existe un contacto llamado {contacto['nombre']}")
        self.contactos.append(contacto)

    def buscar(self, texto):
        texto = texto.lower()
        return [c for c in self.contactos if texto in c["nombre"].lower()]

    def eliminar(self, nombre):
        contacto = self._buscar_exacto(nombre)
        if contacto is None:
            return False
        self.contactos.remove(contacto)
        return True

    def listar(self):
        return sorted(self.contactos, key=lambda c: c["nombre"].lower())

Detalles de diseño:

  • __init__ acepta una lista ya hecha (la usaremos al cargar el JSON). Se usa None como valor por defecto en lugar de [] para evitar el clásico error de la lista mutable compartida entre todas las agendas.
  • __len__ permite escribir len(agenda). Es un método especial.
  • _buscar_exacto empieza por guion bajo: por convención, es de uso interno.
  • agregar rechaza duplicados con una excepción; eliminar devuelve True o False según haya encontrado el contacto.
  • listar devuelve los contactos ordenados por nombre sin alterar el orden interno.

Pruébala:

agenda = Agenda()
agenda.agregar(nuevo_contacto("Lucía Gómez", "600111222", "lucia@correo.es"))
agenda.agregar(nuevo_contacto("Íñigo Ruiz", "611333444"))
agenda.agregar(nuevo_contacto("Ana Lucas", "622555666"))
print(len(agenda))   # 3

for c in agenda.buscar("lu"):
    print(formatear(c))
# Lucía Gómez    600111222  <lucia@correo.es>
# Ana Lucas      622555666

try:
    agenda.agregar(nuevo_contacto("ana lucas", "699000111"))
except ValueError as error:
    print("Error:", error)
# Error: Ya existe un contacto llamado ana lucas

print(agenda.eliminar("Íñigo Ruiz"))   # True
print(agenda.eliminar("Nadie"))        # False
print([c["nombre"] for c in agenda.listar()])
# ['Ana Lucas', 'Lucía Gómez']

La búsqueda de “lu” encuentra tanto “Lucía” como “Ana Lucas”: busca en cualquier parte del nombre.

Paso 3: guardar y cargar con JSON

Los contactos son listas de diccionarios con textos: justo lo que JSON sabe guardar. Dos funciones bastan:

import json
from pathlib import Path


def guardar_agenda(agenda, ruta):
    with open(ruta, "w", encoding="utf-8") as f:
        json.dump(agenda.contactos, f, indent=2, ensure_ascii=False)


def cargar_agenda(ruta):
    """Lee la agenda del fichero; si no existe o está dañado, devuelve una vacía."""
    try:
        with open(ruta, encoding="utf-8") as f:
            return Agenda(json.load(f))
    except FileNotFoundError:
        return Agenda()
    except json.JSONDecodeError:
        print("Aviso: el fichero está dañado; se empieza con una agenda vacía.")
        return Agenda()
  • indent=2 hace el fichero legible y ensure_ascii=False conserva tildes y eñes tal cual (sin \u00ed).
  • Al cargar, hay dos situaciones normales que no deben romper el programa: la primera vez no hay fichero (FileNotFoundError) y un fichero editado a mano puede quedar roto (json.JSONDecodeError).

Pruébalo con un fichero temporal:

RUTA_DEMO = Path("agenda_demo.json")
guardar_agenda(agenda, RUTA_DEMO)
print(RUTA_DEMO.read_text(encoding="utf-8"))
# [
#   {
#     "nombre": "Lucía Gómez",
#     "telefono": "600111222",
#     "email": "lucia@correo.es"
#   },
#   {
#     "nombre": "Ana Lucas",
#     "telefono": "622555666",
#     "email": ""
#   }
# ]

otra = cargar_agenda(RUTA_DEMO)
print(len(otra), [c["nombre"] for c in otra.listar()])
# 2 ['Ana Lucas', 'Lucía Gómez']
print(len(cargar_agenda("no_existe.json")))
# 0
RUTA_DEMO.write_text("{esto no es json", encoding="utf-8")
print(len(cargar_agenda(RUTA_DEMO)))
# Aviso: el fichero está dañado; se empieza con una agenda vacía.
# 0
RUTA_DEMO.unlink()

unlink() borra el fichero de prueba al terminar.

Paso 4: la lógica de las opciones, sin input()

Aquí está el truco del proyecto. Una función procesar_opcion recibe la agenda, la opción elegida y los datos ya recogidos, y devuelve el mensaje que hay que mostrar. No pregunta nada ni imprime:

def procesar_opcion(agenda, opcion, *datos):
    """Ejecuta una opción del menú y devuelve el mensaje a mostrar."""
    if opcion == "1":
        nombre, telefono, email = datos
        try:
            agenda.agregar(nuevo_contacto(nombre, telefono, email))
        except ValueError as error:
            return f"No se ha guardado: {error}"
        return f"Contacto {nombre.strip()} guardado."
    if opcion == "2":
        (texto,) = datos
        encontrados = agenda.buscar(texto)
        if not encontrados:
            return "Sin resultados."
        return "\n".join(formatear(c) for c in encontrados)
    if opcion == "3":
        if len(agenda) == 0:
            return "La agenda está vacía."
        return "\n".join(formatear(c) for c in agenda.listar())
    if opcion == "4":
        (nombre,) = datos
        return "Contacto eliminado." if agenda.eliminar(nombre) else "No existe ese contacto."
    return "Opción no válida."

*datos recoge cuantos argumentos extra se pasen, como se explica en funciones avanzadas. Cada opción desempaqueta los que necesita. Aquí se capturan los ValueError de las validaciones y se convierten en un mensaje para el usuario.

Y como no hay input(), la podemos probar ahora mismo con datos de prueba:

prueba = Agenda()
print(procesar_opcion(prueba, "1", "Marta Peña", "633777888", "marta@correo.es"))
# Contacto Marta Peña guardado.
print(procesar_opcion(prueba, "1", "Marta Peña", "633777888", ""))
# No se ha guardado: Ya existe un contacto llamado Marta Peña
print(procesar_opcion(prueba, "1", "Pablo", "123", ""))
# No se ha guardado: El teléfono debe tener 9 dígitos
print(procesar_opcion(prueba, "2", "mar"))
# Marta Peña     633777888  <marta@correo.es>
print(procesar_opcion(prueba, "2", "xyz"))
# Sin resultados.
print(procesar_opcion(prueba, "4", "Marta Peña"))
# Contacto eliminado.
print(procesar_opcion(prueba, "3"))
# La agenda está vacía.
print(procesar_opcion(prueba, "9"))
# Opción no válida.

Paso 5: el menú con input()

Lo único que falta es la capa que habla con el usuario. Esta parte solo funciona en tu ordenador: copia este bloque al final del archivo agenda.py junto con lo anterior.

# NO EJECUTAR EN LA WEB: usa input(); cópialo a tu ordenador
TEXTO_MENU = """
=== AGENDA ===
1) Añadir contacto
2) Buscar
3) Ver todos
4) Eliminar
0) Salir"""


def menu(agenda, ruta):
    while True:
        print(TEXTO_MENU)
        opcion = input("Elige una opción: ").strip()
        if opcion == "0":
            print("Hasta pronto.")
            break
        if opcion == "1":
            datos = (input("Nombre: "), input("Teléfono: "), input("Email (opcional): "))
        elif opcion == "2":
            datos = (input("Texto a buscar: "),)
        elif opcion == "4":
            datos = (input("Nombre exacto: "),)
        else:
            datos = ()
        print(procesar_opcion(agenda, opcion, *datos))
        if opcion in ("1", "4"):
            guardar_agenda(agenda, ruta)


if __name__ == "__main__":
    RUTA = Path("agenda.json")
    menu(cargar_agenda(RUTA), RUTA)

El menú es un while True del que se sale con break (lo viste en bucles). Su trabajo es muy simple: preguntar lo que haga falta, llamar a procesar_opcion, imprimir el resultado y guardar tras añadir o eliminar. Así, aunque el programa se cierre de golpe, no se pierde nada.

if __name__ == "__main__": hace que el menú arranque solo cuando ejecutas el archivo directamente, no cuando lo importas desde otro programa. Lo explica la lección de módulos.

Código final completo

Este es el archivo agenda.py entero. Guárdalo, ejecútalo con python agenda.py y prueba a añadir contactos, cerrar el programa y volver a abrirlo.

# NO EJECUTAR EN LA WEB: programa completo con input(); cópialo a tu ordenador
import json
from pathlib import Path


def nuevo_contacto(nombre, telefono, email=""):
    """Crea un contacto (diccionario) validando los datos."""
    nombre = nombre.strip()
    telefono = telefono.replace(" ", "")
    if not nombre:
        raise ValueError("El nombre no puede estar vacío")
    if not telefono.isdigit() or len(telefono) != 9:
        raise ValueError("El teléfono debe tener 9 dígitos")
    return {"nombre": nombre, "telefono": telefono, "email": email.strip().lower()}


def formatear(contacto):
    """Devuelve el contacto como una línea de texto."""
    linea = f"{contacto['nombre']:<14} {contacto['telefono']}"
    if contacto["email"]:
        linea += f"  <{contacto['email']}>"
    return linea




class Agenda:
    """Colección de contactos en memoria."""

    def __init__(self, contactos=None):
        self.contactos = contactos if contactos is not None else []

    def __len__(self):
        return len(self.contactos)

    def _buscar_exacto(self, nombre):
        for c in self.contactos:
            if c["nombre"].lower() == nombre.strip().lower():
                return c
        return None

    def agregar(self, contacto):
        if self._buscar_exacto(contacto["nombre"]):
            raise ValueError(f"Ya existe un contacto llamado {contacto['nombre']}")
        self.contactos.append(contacto)

    def buscar(self, texto):
        texto = texto.lower()
        return [c for c in self.contactos if texto in c["nombre"].lower()]

    def eliminar(self, nombre):
        contacto = self._buscar_exacto(nombre)
        if contacto is None:
            return False
        self.contactos.remove(contacto)
        return True

    def listar(self):
        return sorted(self.contactos, key=lambda c: c["nombre"].lower())


def guardar_agenda(agenda, ruta):
    with open(ruta, "w", encoding="utf-8") as f:
        json.dump(agenda.contactos, f, indent=2, ensure_ascii=False)


def cargar_agenda(ruta):
    """Lee la agenda del fichero; si no existe o está dañado, devuelve una vacía."""
    try:
        with open(ruta, encoding="utf-8") as f:
            return Agenda(json.load(f))
    except FileNotFoundError:
        return Agenda()
    except json.JSONDecodeError:
        print("Aviso: el fichero está dañado; se empieza con una agenda vacía.")
        return Agenda()


def procesar_opcion(agenda, opcion, *datos):
    """Ejecuta una opción del menú y devuelve el mensaje a mostrar."""
    if opcion == "1":
        nombre, telefono, email = datos
        try:
            agenda.agregar(nuevo_contacto(nombre, telefono, email))
        except ValueError as error:
            return f"No se ha guardado: {error}"
        return f"Contacto {nombre.strip()} guardado."
    if opcion == "2":
        (texto,) = datos
        encontrados = agenda.buscar(texto)
        if not encontrados:
            return "Sin resultados."
        return "\n".join(formatear(c) for c in encontrados)
    if opcion == "3":
        if len(agenda) == 0:
            return "La agenda está vacía."
        return "\n".join(formatear(c) for c in agenda.listar())
    if opcion == "4":
        (nombre,) = datos
        return "Contacto eliminado." if agenda.eliminar(nombre) else "No existe ese contacto."
    return "Opción no válida."


TEXTO_MENU = """
=== AGENDA ===
1) Añadir contacto
2) Buscar
3) Ver todos
4) Eliminar
0) Salir"""


def menu(agenda, ruta):
    while True:
        print(TEXTO_MENU)
        opcion = input("Elige una opción: ").strip()
        if opcion == "0":
            print("Hasta pronto.")
            break
        if opcion == "1":
            datos = (input("Nombre: "), input("Teléfono: "), input("Email (opcional): "))
        elif opcion == "2":
            datos = (input("Texto a buscar: "),)
        elif opcion == "4":
            datos = (input("Nombre exacto: "),)
        else:
            datos = ()
        print(procesar_opcion(agenda, opcion, *datos))
        if opcion in ("1", "4"):
            guardar_agenda(agenda, ruta)


if __name__ == "__main__":
    RUTA = Path("agenda.json")
    menu(cargar_agenda(RUTA), RUTA)

Una sesión de ejemplo, con lo que escribe el usuario después de cada pregunta:

Elige una opción: 1
Nombre: Lucía Gómez
Teléfono: 600111222
Email (opcional): lucia@correo.es
Contacto Lucía Gómez guardado.

Al cerrar y volver a abrir el programa, la opción 3 mostrará a Lucía: está en agenda.json.

Ideas para ampliar el proyecto

  • Editar contactos. Una opción 5 que cambie el teléfono o el email de un contacto existente.
  • Más campos. Cumpleaños con el módulo datetime (fechas) y un listado de próximos cumpleaños.
  • Exportar a CSV para abrir la agenda en una hoja de cálculo (CSV).
  • SQLite en lugar de JSON, para agendas grandes y búsquedas rápidas (SQLite).
  • Una clase Contacto en lugar de diccionarios, con @dataclass o con métodos especiales.
  • Pruebas automáticas de Agenda y procesar_opcion con pytest: es fácil porque no usan input().
  • Copia de seguridad que guarde una copia del JSON antes de cada cambio.

Errores frecuentes

  • Mezclar input() con la lógica. Si la función que añade contactos pregunta por teclado, no se puede probar ni reutilizar. Separa siempre datos, lógica e interfaz.
  • Usar [] como valor por defecto de un parámetro. La lista se comparte entre todas las llamadas. Usa None y crea la lista dentro.
  • Guardar solo al salir. Si el programa se cierra de golpe, se pierden los cambios. Guarda tras cada modificación.
  • Olvidar encoding="utf-8" y ensure_ascii=False. Las tildes se estropean o se guardan como \u00ed.
  • No controlar el fichero inexistente o dañado. El programa fallaría la primera vez que se ejecuta.
  • Comparar nombres sin normalizar. “Ana” y “ana” serían contactos distintos si no usas lower().
  • Teléfonos como números. Se guardan como texto: un int perdería el cero inicial y no sirve para operar.

Resumen

PiezaPara qué sirveConcepto
nuevo_contactoCrear y validar un contactoFunciones, diccionarios, ValueError
formatearMostrar un contactof-strings con ancho
AgendaGuardar y gestionar contactosClases y métodos
guardar_agenda / cargar_agendaPersistir los datosjson.dump, json.load, with
procesar_opcionLógica del menú sin input()*datos, funciones que devuelven mensajes
menuHablar con el usuarioinput(), while True, break
if __name__ == "__main__"Arrancar solo al ejecutar el archivoMódulos

Con esto terminas el curso de Python con un programa completo y bien organizado. Ahora puedes repasar con las pruebas automáticas o dar el salto a la web con el curso de PHP.

Pon a prueba lo que has aprendido

[Python] ¿Qué ventaja tiene que procesar_opcion no use input() y devuelva un mensaje?

[Python] ¿Qué imprime este código?
def nuevo_contacto(nombre, telefono, email=""):
    nombre = nombre.strip()
    telefono = telefono.replace(" ", "")
    if not telefono.isdigit() or len(telefono) != 9:
        raise ValueError("El teléfono debe tener 9 dígitos")
    return {"nombre": nombre, "telefono": telefono, "email": email}

print(nuevo_contacto("  Ana ", "611 333 444")["telefono"])

[Python] ¿Qué problema tiene esta clase si creas dos agendas con Agenda()?
class Agenda:
    def __init__(self, contactos=[]):
        self.contactos = contactos

[Python] ¿Para qué sirve ensure_ascii=False en json.dump(contactos, f, indent=2, ensure_ascii=False)?

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.