Saltar a contenido

1.18 · Proyecto final de la unidad

Objetivos. Al terminar este proyecto podrás: convertir un problema pequeño en un programa Python mantenible; separar datos, lógica e interfaz; elegir estructuras y módulos; persistir resultados; manejar fallos; y justificar la calidad de una solución con pruebas.

Evidencia de logro. Entregarás una aplicación ejecutable, tipada y testeada que procese una entrada reproducible, produzca una salida útil y documente sus decisiones. La aplicación será el cierre de la progresión script → datos → funciones → módulos → objetos → proyecto verificado.

Antes de empezar

Es el proyecto final de la unidad: usa todo lo de los capítulos anteriores. Antes de empezar, comprueba que puedes ejecutar la práctica de 1.17 · Type hints y testing, porque el entregable se evalúa con pytest.

Contexto y motivación

Un proyecto final no consiste en juntar todas las APIs aprendidas. Consiste en definir un problema acotado y demostrar que sabes construir una solución que otra persona puede ejecutar, entender y comprobar.

Puedes trabajar con un asistente de notas, un analizador de texto, un monitor de tendencias o un generador de fractales. En todos los casos se evalúa el mismo núcleo ingenieril; el tema no debe ocultar la ausencia de un contrato claro.

Prerrequisitos

1.11 · Excepciones y logging, 1.12 · Módulos y paquetes, 1.13 · POO, 1.14 · Biblioteca estándar, 1.15 · Librerías externas, 1.16 · Asincronía y 1.17 · Type hints y testing.

La asincronía es opcional en el núcleo del proyecto; tipos, errores y pruebas son obligatorios.

1. Elegir un problema acotado

Escoge una propuesta o formula otra con el mismo tamaño:

Opción Entrada Salida mínima Ampliación
Asistente de notas JSON local con notas búsqueda, filtrado y resumen consulta HTTP simulada
Analizador de sentimientos CSV o lista de reseñas etiqueta y recuento por etiqueta comparación con un modelo externo
Monitor de tendencias CSV de publicaciones palabras más frecuentes y evolución consulta asíncrona simulada
Chatbot de reglas mensajes de texto respuesta y estado de conversación persistencia de historial
Fractales Mandelbrot parámetros numéricos matriz o imagen reproducible explorador interactivo

Un analizador basado en palabras positivas y negativas es un baseline de reglas, no un modelo de aprendizaje automático. Describe con precisión qué hace tu programa y no presentes una heurística como inteligencia general.

1.1 Contrato mínimo

Antes de programar, escribe una ficha de una página:

  • Usuario y problema: quién lo usa y qué decisión o tarea facilita.
  • Entradas: formato, tipos, campos obligatorios y valores inválidos.
  • Salida: estructura, significado y ejemplo.
  • Restricciones: sin red, sin claves, tiempo y tamaño de datos.
  • Criterio de éxito: tres casos comprobables.
  • Fuera de alcance: funcionalidades que no intentarás en esta entrega.

Si no puedes escribir un caso de entrada y una salida esperada, el problema aún no está suficientemente definido.

2. Estructura recomendada

Parte de esta estructura y cambia los nombres según tu dominio:

proyecto_final/
├── pyproject.toml
├── README.md
├── datos/
│   └── ejemplo.json
├── asistente/
│   ├── __init__.py
│   ├── modelo.py
│   ├── servicio.py
│   └── cli.py
└── tests/
    ├── test_modelo.py
    └── test_servicio.py

Responsabilidades:

Módulo Responsabilidad
modelo.py Clases, Enum e invariantes del dominio
servicio.py Funciones que transforman datos y coordinan operaciones
cli.py Entrada/salida de terminal y códigos de respuesta
tests/ Contratos automatizados
datos/ Casos de ejemplo pequeños y públicos

La interfaz no debería volver a calcular la lógica del servicio, y el modelo no debería llamar a input() ni escribir directamente en la terminal.

Recuerda añadir a pyproject.toml la configuración de 1.17 para que las pruebas de tests/ puedan importar tu paquete:

[tool.pytest.ini_options]
pythonpath = ["."]

3. Construir una línea de base

Empieza con el caso más pequeño que funcione. Por ejemplo, un clasificador local puede definir primero un contrato explícito:

from collections import Counter


PALABRAS_POSITIVAS = {"útil", "bueno", "claro"}
PALABRAS_NEGATIVAS = {"difícil", "malo", "lento"}


def clasificar(texto: str) -> str:
    if not texto.strip():
        raise ValueError("El texto no puede estar vacío")
    palabras = set(texto.lower().split())
    positivas = len(palabras & PALABRAS_POSITIVAS)
    negativas = len(palabras & PALABRAS_NEGATIVAS)
    if positivas > negativas:
        return "positivo"
    if negativas > positivas:
        return "negativo"
    return "neutro"


def resumir(etiquetas: list[str]) -> dict[str, int]:
    return dict(Counter(etiquetas))


etiquetas = [clasificar(texto) for texto in [
    "el resultado es útil y claro",
    "el proceso es lento",
    "sin opinión",
]]
print(etiquetas)
print(resumir(etiquetas))

Salida esperada:

['positivo', 'negativo', 'neutro']
{'positivo': 1, 'negativo': 1, 'neutro': 1}

La línea de base permite comprobar el flujo antes de añadir clases, persistencia o una librería externa. En tu proyecto sustituye este ejemplo por una operación propia y conserva una salida pequeña que sirva como referencia.

4. Integrar clases y estados

Cuando el dominio tiene identidad y estados, usa el modelo de 1.13:

from enum import Enum


class Estado(Enum):
    NUEVO = "nuevo"
    PROCESADO = "procesado"
    ERROR = "error"


class Resultado:
    def __init__(self, entrada: str, etiqueta: str):
        self.entrada = entrada
        self.etiqueta = etiqueta
        self.estado = Estado.PROCESADO

    def como_dict(self) -> dict[str, str]:
        return {
            "entrada": self.entrada,
            "etiqueta": self.etiqueta,
            "estado": self.estado.value,
        }


resultado = Resultado("mensaje de ejemplo", "neutro")
print(resultado.como_dict())

Salida esperada:

{'entrada': 'mensaje de ejemplo', 'etiqueta': 'neutro', 'estado': 'procesado'}

El ejemplo no exige crear una jerarquía de clases. Si tu dominio no necesita herencia, composición y funciones pueden ser una decisión mejor. La calidad se mide por la adecuación al problema, no por el número de decoradores o clases.

5. Persistencia y errores

Guarda resultados en JSON con Path, valida las entradas y registra operaciones importantes. Reutiliza las funciones de 1.10 y el manejo de 1.11; no copies bloques en cada módulo.

Una ruta de datos de ejemplo debe poder reconstruirse desde cero. No dependas de un fichero creado manualmente en una ejecución anterior: incluye el caso mínimo en datos/ejemplo.json o créalo explícitamente durante la práctica.

Comprueba al menos:

  • fichero ausente;
  • JSON inválido o campo obligatorio ausente;
  • entrada válida vacía, si el dominio la permite;
  • escritura y lectura de la salida.

No trates un JSON corrupto como si fuera una lista vacía sin registrarlo: podrías perder información y no saber que la aplicación está trabajando con datos incompletos.

6. Tipos y pruebas

Cada función pública debe tener anotaciones útiles. Prueba comportamientos, no la forma exacta del código:

import pytest

from asistente.servicio import clasificar


@pytest.mark.parametrize(
    ("texto", "esperada"),
    [
        ("útil y claro", "positivo"),
        ("muy lento", "negativo"),
        ("sin coincidencias", "neutro"),
    ],
)
def test_clasificar(texto: str, esperada: str) -> None:
    assert clasificar(texto) == esperada


def test_entrada_no_valida():
    with pytest.raises(ValueError, match="no puede estar vacío"):
        clasificar("  ")

El error esperado debe corresponder a una regla del contrato: aquí no se admite un texto vacío. Si tu proyecto acepta entradas vacías, sustituye el caso por otra precondición que sí exista y documenta la decisión. No entregues una prueba vacía solo para aumentar el número de tests.

Ejecuta:

uv run pytest -q

Salida esperada:

... passed in ...s

Conserva el número real de pruebas y revisa que una prueba que falla haga fallar la entrega. Una suite que siempre pasa porque no contiene aserciones no aporta evidencia.

7. Plan de trabajo en cuatro horas

Fase Tiempo orientativo Evidencia
Contrato y diseño 30 min ficha de problema, entradas y salidas
Línea de base 45 min caso mínimo ejecutado
Organización 60 min módulos y responsabilidades
Robustez 45 min errores, persistencia y logging
Pruebas y documentación 45 min suite, README y decisiones
Revisión final 15 min ejecución desde cero y lista de límites

El proyecto puede continuar con trabajo autónomo. No añadas una funcionalidad nueva si la anterior no tiene un caso de aceptación.

8. README y reproducibilidad

El README.md debe incluir:

  1. problema y fuera de alcance;
  2. instalación (uv sync --locked si el proyecto incluye uv.lock; en otro caso, uv sync para resolver las dependencias);
  3. ejecución (uv run python -m asistente.cli o equivalente);
  4. pruebas (uv run pytest);
  5. ejemplo de entrada y salida;
  6. estructura de módulos;
  7. decisiones, limitaciones y posibles mejoras.

Haz una prueba limpia: desde una copia o después de borrar los resultados generados, ejecuta la instalación, la aplicación y los tests. Esa comprobación revela dependencias ocultas y rutas escritas a mano.

Entrega y criterios de aceptación

Entrega 14_proyecto_final.py como cuaderno marimo de presentación o script de entrada, el proyecto completo y la carpeta tests/. El código debe:

  • ejecutarse con uv run;
  • separar interfaz, lógica y modelo;
  • usar al menos una función tipada y una estructura de datos adecuada;
  • persistir o leer datos de forma reproducible;
  • manejar un fallo previsible y registrarlo;
  • tener pruebas del caso normal, un límite y un error;
  • incluir una conclusión que interprete el resultado y sus límites.

Rúbrica orientativa

Criterio Peso Evidencia
Contrato y utilidad 20 % problema acotado y salidas interpretadas
Diseño 25 % módulos, funciones, clases y estructuras justificadas
Robustez 20 % validación, excepciones, persistencia y logging
Verificación 25 % tests deterministas y límites relevantes
Reproducibilidad 10 % README, uv.lock y ejecución desde cero

Una ampliación asíncrona o una librería externa no compensa un núcleo que no se puede ejecutar o comprobar. Documenta cualquier dependencia opcional y ofrece siempre una ruta local para evaluar el contrato básico.

Errores frecuentes

  • Elegir un proyecto demasiado grande y entregar solo una demo incompleta.
  • Añadir una API externa antes de comprobar el flujo con datos locales.
  • Llamar «IA» a una regla sin explicar su alcance y sus limitaciones.
  • Mezclar input, persistencia, lógica y presentación en una sola función.
  • Usar rutas absolutas o ficheros que no se incluyen en la entrega.
  • Capturar errores y continuar con datos vacíos sin dejar registro.
  • Crear tests que dependen de la hora, la red o el estado de otra prueba.
  • Confundir cantidad de código con evidencia de aprendizaje.

Checklist antes de entregar

  • El contrato tiene una entrada y una salida esperada.
  • El caso mínimo funciona desde una carpeta limpia.
  • Las funciones públicas tienen anotaciones útiles.
  • El programa distingue un fallo recuperable de un bug inesperado.
  • Hay logging suficiente para investigar una operación fallida.
  • uv run pytest -q pasa y las pruebas contienen aserciones.
  • El README explica instalar, ejecutar, probar y las limitaciones.
  • uv.lock está actualizado y versionado; .env y cualquier clave no lo están.

Resumen y referencia rápida

Este capítulo no introduce APIs nuevas: reúne los patrones de la unidad que el proyecto debe demostrar.

Necesitas Capítulo Patrón
Validar y rechazar entradas 1.11 raise ValueError("...")
Registrar operaciones 1.11 logger.info(...) / logger.exception(...)
Separar responsabilidades 1.12 módulos con una responsabilidad cada uno
Punto de entrada 1.12 if __name__ == "__main__":
Estado del dominio 1.13 clase con @property validada y Enum
Fechas, CSV, conteos 1.14 datetime, csv.DictReader, Counter
Configuración externa 1.15 os.environ.get o .env sin secretos
Contrato de funciones 1.17 anotaciones de tipo en parámetros y retorno
Verificación 1.17 pytest.raises, parametrize, tmp_path

Cierre de la unidad

Has pasado de ejecutar instrucciones sueltas a construir un programa con responsabilidades, contratos y evidencia. En la UD2 reutilizarás esta disciplina para crear una API: las funciones se convertirán en rutas, la validación será un contrato HTTP y los tests comprobarán el comportamiento de la aplicación.