Tabla de Contenidos
Proyecto final integrador
Del requisito al programa probado, documentado y portable.
Este módulo forma parte del curso de programación desde cero con Python
Módulo 22. Proyecto final integrador
Has llegado al final del recorrido. A lo largo de veintiún módulos has aprendido a pensar como programador, a escribir código correcto, a probarlo, a documentarlo y a organizarlo en proyectos profesionales. Este módulo no introduce conceptos nuevos: es un taller de síntesis. Vas a construir, desde cero y de principio a fin, una aplicación completa que integra todo lo aprendido. El objetivo no es que el proyecto sea espectacular, sino que sea realista, verificable y defendible: que puedas explicar cada decisión de diseño y demostrar que funciona en Windows y Linux.
El proyecto que propongo es un Gestor de Gastos Personales con interfaz de línea de comandos. Es lo bastante pequeño para terminarlo en unas horas y lo bastante completo para ejercitar funciones, clases, colecciones, archivos, excepciones, pruebas, tipado, documentación, Git y empaquetado. Si prefieres otro dominio (tareas, contactos, inventario), el proceso es idéntico; adapta los nombres y las entidades.
1. Título y alcance
Título: Proyecto final integrador: del requisito al programa probado, documentado y portable.
Alcance: Aplicar el ciclo completo de desarrollo de software a una aplicación real: definir requisitos, diseñar el algoritmo y las estructuras de datos, descomponer en funciones y clases, implementar incrementalmente, gestionar la persistencia en JSON, validar y tratar errores, escribir pruebas automatizadas con pytest, medir cobertura, refactorizar, documentar con docstrings y README, versionar con Git, empaquetar con pyproject.toml y verificar la ejecución en Windows y Linux. Se presupone todo el curso (Módulos 1–21), en particular funciones (Módulo 9), POO (Módulo 14), archivos (Módulo 13), excepciones (Módulo 11), pruebas (Módulo 17), tipado/documentación (Módulo 18) y gestión de proyectos (Módulo 19).
2. Objetivos de aprendizaje
Al terminar este módulo serás capaz de:
1. Transformar un enunciado ambiguo en una especificación de requisitos verificables.
2. Diseñar una arquitectura sencilla con separación de responsabilidades (dominio, persistencia, interfaz).
3. Elegir las estructuras de datos adecuadas y justificar la elección.
4. Implementar la lógica con funciones puras y clases donde aporten valor.
5. Gestionar la persistencia en JSON con rutas portables y codificación UTF-8.
6. Validar entradas y tratar errores con excepciones específicas.
7. Escribir pruebas automatizadas (normales, límite, inválidas) y medir cobertura.
8. Refactorizar sin cambiar el comportamiento, apoyándote en las pruebas.
9. Documentar el proyecto con docstrings (PEP 257), README y, opcionalmente, Sphinx.
10. Versionar el proyecto con Git y un .gitignore correcto.
11. Empaquetar el proyecto con pyproject.toml y ejecutarlo en Windows y Linux.
12. Presentar y defender las decisiones de diseño con criterios técnicos.
3. Conocimientos previos
- Todo el curso hasta el Módulo 21.
- Manejo de la terminal (Módulo 19), Git, entornos virtuales y pyproject.toml.
- Pruebas con pytest y cobertura (Módulo 17).
- Tipado con mypy (Módulo 18).
- Formato y linting con Ruff (Módulo 16).
4. Introducción motivadora: el proyecto que demuestra lo que sabes
Un curso no se completa con exámenes teóricos: se completa cuando puedes construir algo que funcione. El proyecto final es tu carta de presentación. No importa que sea una aplicación de consola: importa que tenga requisitos claros, que esté bien estructurada, que tenga pruebas que demuestren que funciona, que esté documentada para que otra persona pueda usarla y que puedas explicar por qué elegiste cada pieza. Ese es el estándar profesional que este módulo te ayuda a alcanzar.
5. Explicación paso a paso del proyecto
5.1. Definición de requisitos
Enunciado propuesto: “Una persona quiere registrar sus gastos diarios y obtener un resumen por categoría. La aplicación debe funcionar sin conexión, guardar los datos entre ejecuciones y poder usarse desde la terminal.”
Requisitos funcionales (RF):
| ID | Requisito | Criterio de aceptación |
|---|---|---|
| RF1 | Añadir un gasto con fecha, concepto, categoría e importe. | El gasto se guarda y aparece en el listado. |
| RF2 | Listar todos los gastos, opcionalmente filtrados por categoría. | Se muestran los gastos ordenados por fecha. |
| RF3 | Calcular el total gastado y el total por categoría. | Los totales coinciden con la suma manual. |
| RF4 | Eliminar un gasto por su identificador. | El gasto desaparece y el total se actualiza. |
| RF5 | Persistir los datos en un archivo JSON. | Al cerrar y abrir la aplicación, los datos se conservan. |
Requisitos no funcionales (RNF):
- Portabilidad: debe funcionar en Windows y Linux sin cambios.
- Robustez: las entradas inválidas no deben romper la aplicación.
- Mantenibilidad: código modular, con funciones pequeñas y pruebas.
- Documentación: docstrings en funciones públicas y un README con instrucciones.
[BUENA PRÁCTICA] Escribe los requisitos antes de programar. Si no puedes describir qué entra y qué sale, no puedes diseñar ni probar. Este es el hábito más importante del Módulo 10.
5.2. Diseño del algoritmo y estructuras de datos
Entidad principal: un gasto. ¿Cómo representarlo? Un diccionario con claves fijas es simple, pero no expresa el contrato. Una dataclass (Módulo 14) es más clara y permite tipado estático.
from dataclasses import dataclass, field from datetime import date @dataclass class Gasto: """Representa un gasto con fecha, concepto, categoría e importe.""" fecha: date concepto: str categoria: str importe: float id: int = field(default=0) # 0 = sin asignar
Colección de gastos: una lista de Gasto. El orden de inserción importa y se admiten duplicados. Para las categorías, un diccionario categoria -> total permite acumular de forma eficiente.
Persistencia: JSON (Módulo 13). Es legible, portable y no requiere dependencias externas. Se guarda una lista de diccionarios y se reconstruyen los Gasto al cargar.
Arquitectura en tres capas:
- Dominio (src/gastos/dominio.py): clases y funciones puras de cálculo (totales, filtros).
- Persistencia (src/gastos/almacen.py): carga y guarda JSON.
- Interfaz (src/gastos/cli.py): argparse y presentación por terminal.
[BUENA PRÁCTICA] Separar dominio, persistencia e interfaz permite probar la lógica sin tocar archivos y cambiar la interfaz sin tocar el dominio. Es una aplicación práctica del principio de responsabilidad única (Módulo 16).
5.3. Estructura del proyecto (`src/` layout)
gestor-gastos/
├── pyproject.toml
├── README.md
├── .gitignore
├── src/
│ └── gastos/
│ ├── __init__.py
│ ├── dominio.py
│ ├── almacen.py
│ └── cli.py
├── tests/
│ ├── test_dominio.py
│ └── test_almacen.py
└── datos/
└── gastos.json
[PYTHON] El diseñosrc/(Módulo 19) evita importaciones accidentales y garantiza que las pruebas se ejecuten contra el paquete instalado, no contra una copia local.
5.4. Configuración del proyecto (`pyproject.toml`)
[build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "gestor-gastos" version = "0.1.0" description = "Gestor de gastos personales por línea de comandos" readme = "README.md" requires-python = ">=3.11" dependencies = [] [project.optional-dependencies] dev = ["pytest", "pytest-cov", "ruff", "mypy"] [project.scripts] gestor-gastos = "gastos.cli:main" [tool.ruff] line-length = 88 target-version = "py311" [tool.pytest.ini_options] testpaths = ["tests"] addopts = "--cov=gastos --cov-report=term-missing" [tool.mypy] strict = true
[PYTHON]pyproject.tomles la fuente única de verdad del proyecto (Módulo 19). La tabla[project.scripts]crea un comandogestor-gastosque ejecutamain()decli.py. Es una forma limpia de distribuir aplicaciones de consola.
5.5. Implementación del dominio (funciones puras)
# src/gastos/dominio.py from collections import defaultdict from dataclasses import dataclass, field from datetime import date @dataclass class Gasto: """Representa un gasto.""" fecha: date concepto: str categoria: str importe: float id: int = field(default=0) def total_gastado(gastos: list[Gasto]) -> float: """Devuelve la suma de los importes de todos los gastos.""" return sum(g.importe for g in gastos) def total_por_categoria(gastos: list[Gasto]) -> dict[str, float]: """Devuelve un diccionario categoría -> total gastado.""" totales: dict[str, float] = defaultdict(float) for gasto in gastos: totales[gasto.categoria] += gasto.importe return dict(totales) def filtrar_por_categoria(gastos: list[Gasto], categoria: str) -> list[Gasto]: """Devuelve los gastos de una categoría concreta.""" return [g for g in gastos if g.categoria.lower() == categoria.lower()] def siguiente_id(gastos: list[Gasto]) -> int: """Devuelve el siguiente identificador disponible.""" return max((g.id for g in gastos), default=0) + 1
Justificación: todas son funciones puras (Módulo 9): no leen archivos, no imprimen y no dependen de estado global. Son triviales de probar y de componer.
[PYTHON] En Python 3.15 podrías usarfrozendictpara el resultado detotal_por_categoriasi quisieras garantizar que el resumen no se modifique por accidente. Es opcional, pero coherente con la idea de inmutabilidad declarada.
5.6. Implementación de la persistencia (JSON)
# src/gastos/almacen.py import json from datetime import date from pathlib import Path from gastos.dominio import Gasto def _gasto_a_dict(gasto: Gasto) -> dict: return { "id": gasto.id, "fecha": gasto.fecha.isoformat(), "concepto": gasto.concepto, "categoria": gasto.categoria, "importe": gasto.importe, } def _dict_a_gasto(datos: dict) -> Gasto: return Gasto( id=datos["id"], fecha=date.fromisoformat(datos["fecha"]), concepto=datos["concepto"], categoria=datos["categoria"], importe=datos["importe"], ) def cargar_gastos(ruta: Path) -> list[Gasto]: """Carga los gastos desde un archivo JSON. Si no existe, devuelve [].""" if not ruta.exists(): return [] with open(ruta, "r", encoding="utf-8") as f: datos = json.load(f) return [_dict_a_gasto(d) for d in datos] def guardar_gastos(ruta: Path, gastos: list[Gasto]) -> None: """Guarda los gastos en un archivo JSON, creando el directorio si es necesario.""" ruta.parent.mkdir(parents=True, exist_ok=True) with open(ruta, "w", encoding="utf-8") as f: json.dump([_gasto_a_dict(g) for g in gastos], f, ensure_ascii=False, indent=2)
Puntos clave: rutas con pathlib (portable), encoding="utf-8" explícito, ensure_ascii=False para tildes y mkdir(parents=True, exist_ok=True) para no fallar si la carpeta no existe.
[ERROR HABITUAL] Guardardatedirectamente en JSON lanzaTypeError. Hay que convertirlo a cadena con.isoformat()y reconstruirlo condate.fromisoformat(). Es un ejemplo típico de frontera entre el dominio y la persistencia.
5.7. Interfaz de línea de comandos (`argparse`)
# src/gastos/cli.py import argparse from datetime import date from pathlib import Path from gastos.almacen import cargar_gastos, guardar_gastos from gastos.dominio import Gasto, siguiente_id, total_gastado, total_por_categoria RUTA_DATOS = Path.home() / ".gestor-gastos" / "gastos.json" def main() -> None: parser = argparse.ArgumentParser(description="Gestor de gastos personales.") sub = parser.add_subparsers(dest="comando", required=True) add = sub.add_parser("añadir", help="Añade un gasto") add.add_argument("concepto") add.add_argument("categoria") add.add_argument("importe", type=float) sub.add_parser("listar", help="Lista todos los gastos") sub.add_parser("resumen", help="Muestra el total y el desglose por categoría") args = parser.parse_args() gastos = cargar_gastos(RUTA_DATOS) if args.comando == "añadir": gasto = Gasto( id=siguiente_id(gastos), fecha=date.today(), concepto=args.concepto, categoria=args.categoria, importe=args.importe, ) gastos.append(gasto) guardar_gastos(RUTA_DATOS, gastos) print(f"Gasto añadido: {gasto.concepto} ({gasto.importe} €)") elif args.comando == "listar": for g in sorted(gastos, key=lambda x: x.fecha): print(f"[{g.id}] {g.fecha} — {g.concepto} — {g.categoria}: {g.importe} €") elif args.comando == "resumen": print(f"Total: {total_gastado(gastos):.2f} €") for cat, total in total_por_categoria(gastos).items(): print(f" {cat}: {total:.2f} €") if __name__ == "__main__": main()
[BUENA PRÁCTICA]argparsecon subcomandos permite una interfaz clara y extensible. Cada subcomando documenta sus argumentos y genera ayuda automática (--help). Es el patrón habitual en herramientas de consola profesionales.
5.8. Pruebas automatizadas
# tests/test_dominio.py from datetime import date import pytest from gastos.dominio import Gasto, total_gastado, total_por_categoria, filtrar_por_categoria, siguiente_id @pytest.fixture def gastos(): return [ Gasto(id=1, fecha=date(2026, 1, 1), concepto="Pan", categoria="Comida", importe=2.0), Gasto(id=2, fecha=date(2026, 1, 2), concepto="Bus", categoria="Transporte", importe=1.5), Gasto(id=3, fecha=date(2026, 1, 3), concepto="Leche", categoria="Comida", importe=3.0), ] def test_total_gastado(gastos): assert total_gastado(gastos) == 6.5 def test_total_gastado_vacio(): assert total_gastado([]) == 0 def test_total_por_categoria(gastos): totales = total_por_categoria(gastos) assert totales["Comida"] == 5.0 assert totales["Transporte"] == 1.5 def test_filtrar_por_categoria(gastos): comidas = filtrar_por_categoria(gastos, "comida") assert len(comidas) == 2 def test_siguiente_id(gastos): assert siguiente_id(gastos) == 4 def test_siguiente_id_vacio(): assert siguiente_id([]) == 1
Cobertura de casos: normal (gastos), límite (lista vacía), filtro con mayúsculas/minúsculas. Las pruebas de persistencia pueden usar tmp_path de pytest para crear archivos temporales.
# tests/test_almacen.py from pathlib import Path from datetime import date from gastos.almacen import cargar_gastos, guardar_gastos from gastos.dominio import Gasto def test_guardar_y_cargar(tmp_path: Path): ruta = tmp_path / "gastos.json" gastos = [Gasto(id=1, fecha=date(2026, 1, 1), concepto="Pan", categoria="Comida", importe=2.0)] guardar_gastos(ruta, gastos) recuperados = cargar_gastos(ruta) assert recuperados == gastos
[BUENA PRÁCTICA] Prueba el dominio con datos en memoria y la persistencia contmp_path. Así las pruebas son rápidas y no dependen del directorio personal del usuario.
5.9. Refactorización y revisión
Antes de dar el proyecto por terminado, aplica la lista de comprobación del Módulo 16:
- Nombres: ¿gastos, total_gastado, siguiente_id son claros? Sí.
- Duplicación: la conversión Gasto ↔ dict está centralizada en almacen.py.
- Constantes mágicas: RUTA_DATOS es una constante con nombre.
- Complejidad: cli.py orquesta, pero no calcula; el dominio calcula, pero no imprime.
- Ruff: ejecuta ruff check --fix . y ruff format ..
- Mypy: ejecuta mypy src/ y corrige los errores de tipo.
[BUENA PRÁCTICA] La refactorización es más segura cuando las pruebas ya existen. Ejecutapytestdespués de cada cambio pequeño. Si algo se rompe, vuelve atrás.
5.10. Documentación y Git
- Docstrings en todas las funciones públicas (PEP 257).
- README.md con descripción, instalación, uso y pruebas.
- .gitignore con .venv/, __pycache__/, .mypy_cache/, .pytest_cache/, datos/.
- Commits pequeños con mensajes en imperativo.
# Gestor de Gastos Aplicación de consola para registrar y resumir gastos personales. ## Instalación ```bash uv sync
Uso
uv run gestor-gastos añadir "Pan" "Comida" 2.50 uv run gestor-gastos listar uv run gestor-gastos resumen
Pruebas
uv run pytest
```
5.11. Ejecución en Windows y Linux
- Windows: py -m venv .venv, .venv\Scripts\activate, uv run pytest.
- Linux: python3 -m venv .venv, source .venv/bin/activate, uv run pytest.
- Rutas: Path.home() / ".gestor-gastos" / "gastos.json" funciona en ambos sistemas.
- Codificación: encoding="utf-8" garantiza que las tildes se guarden y lean correctamente.
[WINDOWS / LINUX] Si una prueba pasa en Linux y falla en Windows, revisa mayúsculas en nombres de archivo y finales de línea.pathliby UTF-8 explícito eliminan la mayoría de las diferencias.
6. Ejemplo de evaluación y presentación
Criterios de evaluación sugeridos:
| Criterio | Peso | Qué se evalúa |
|---|---|---|
| Requisitos y diseño | 20 % | Claridad de la especificación y justificación de la arquitectura. |
| Funcionalidad | 25 % | Todos los RF cumplen sus criterios de aceptación. |
| Calidad del código | 20 % | Nombres, responsabilidades, DRY, KISS, Ruff limpio. |
| Pruebas | 15 % | Cobertura de casos normales, límite e inválidos; pytest pasa. |
| Documentación | 10 % | Docstrings, README y comentarios útiles. |
| Portabilidad y Git | 10 % | Funciona en Windows y Linux; historial de commits coherente. |
Presentación (5–10 minutos):
1. Problema y requisitos: qué resuelve y qué decide hacer.
2. Demostración: añadir un gasto, listar, resumir, cerrar y reabrir.
3. Decisiones de diseño: por qué dataclass, por qué JSON, por qué separación en capas.
4. Pruebas: mostrar pytest y la cobertura.
5. Dificultades y aprendizaje: qué te costó más y qué harías distinto.
[BUENA PRÁCTICA] Una presentación técnica no es un espectáculo: es una explicación honesta de las decisiones. Reconoce las limitaciones (por ejemplo, “no hay edición de gastos”) y propón mejoras. Eso demuestra madurez profesional.
7. Errores habituales en el proyecto final
| Error | Causa | Cómo evitarlo |
|---|---|---|
| Mezclar lógica y presentación | print() dentro de funciones de cálculo. | Separa dominio y CLI. |
Guardar date en JSON sin convertir | TypeError al serializar. | Usa .isoformat() y date.fromisoformat(). |
| Pruebas que dependen del directorio personal | Uso de Path.home() en pruebas. | Usa tmp_path de pytest. |
| No fijar dependencias de desarrollo | pyproject.toml incompleto. | Declara dev en [project.optional-dependencies]. |
| Commits gigantes | Todo el proyecto en un commit. | Commits pequeños y temáticos. |
| No probar en ambos sistemas | Asumir que funciona en Windows/Linux. | Ejecuta la suite en ambos (o en CI). |
8. Particularidades y novedades de Python 3.15 en el proyecto
- lazy import: si el proyecto creciera y añadieras un módulo de visualización (por ejemplo, matplotlib), podrías cargarlo con lazy import para que el arranque de la CLI no pague ese coste cuando solo se añaden gastos.
- frozendict: podrías usarlo para el resultado de total_por_categoria si quisieras que el resumen fuera inmutable.
- sentinel: podrías usarlo en cargar_gastos para distinguir “archivo no encontrado” de “archivo vacío”, en lugar de devolver siempre [].
- UTF-8 por defecto: garantiza que los conceptos con tildes se guarden y lean igual en Windows y Linux, incluso si olvidas encoding.
9. Windows y Linux
- Comandos: documenta ambos en el README (py vs. python3).
- Rutas: Path.home() y pathlib son portables.
- Entornos virtuales: .venv\Scripts\activate vs. source .venv/bin/activate.
- Pruebas: ejecuta uv run pytest en ambos sistemas. Si usas CI, configura una matriz os: [ubuntu-latest, windows-latest].
10. Buenas prácticas finales
- Especifica antes de codificar. Un requisito claro vale más que cien líneas de código.
- Prueba cada capa por separado. Dominio con datos en memoria; persistencia con archivos temporales.
- Refactoriza con pruebas. Si no tienes pruebas, no refactorices: escribe primero las pruebas.
- Documenta el contrato. Docstrings y README son parte del producto, no un extra.
- Versiona con intención. Cada commit debe contar una historia pequeña y coherente.
- Presenta con honestidad. Reconoce limitaciones y propón mejoras.
11. Ejercicios de ampliación (opcionales)
1. Edición de gastos: añade un comando editar que permita modificar el concepto o el importe de un gasto por su id.
2. Filtro por fecha: añade --desde y --hasta a listar para filtrar por rango de fechas.
3. Exportar a CSV: añade un comando exportar que genere un CSV con todos los gastos (Módulo 13).
4. Cobertura: mide la cobertura con pytest --cov=gastos --cov-branch y añade pruebas hasta superar el 90 % de ramas.
5. Sphinx: genera documentación HTML a partir de las docstrings del dominio (Módulo 18).
12. Resumen
- El proyecto final integra requisitos, diseño, implementación, pruebas, documentación y despliegue.
- La especificación y la arquitectura en capas son tan importantes como el código.
- Las funciones puras y las dataclass simplifican el dominio; la persistencia JSON y argparse completan la aplicación.
- Las pruebas con pytest y tmp_path garantizan que el proyecto funciona y sigue funcionando tras refactorizar.
- Ruff, mypy y pyproject.toml son las herramientas que convierten el proyecto en profesional.
- La portabilidad se logra con pathlib, UTF-8 explícito y pruebas en Windows y Linux.
- Presentar el proyecto es explicar decisiones, no solo demostrar que funciona.
13. Autoevaluación
1. ¿Por qué es importante separar el dominio de la interfaz de usuario? Pon un ejemplo concreto de este proyecto.
2. ¿Qué pruebas escribirías para cargar_gastos si el archivo contiene un JSON malformado?
3. ¿Qué ventaja aporta pyproject.toml frente a un requirements.txt suelto?
4. ¿Cómo verificarías que el proyecto es portable entre Windows y Linux sin tener ambos sistemas a mano?
5. Reto: añade el comando eliminar y escribe la prueba correspondiente. ¿Qué caso límite debes cubrir?
14. Fuentes oficiales
- Documentación oficial de Python 3.15: https://docs.python.org/3.15/
- Novedades de Python 3.15 (lazy import, frozendict, UTF-8, etc.): https://docs.python.org/3/whatsnew/3.15.html
- Guía oficial de pyproject.toml: https://packaging.python.org/en/latest/guides/writing-pyproject-toml/
- Guía oficial de estructura de proyectos (src layout vs flat layout): https://packaging.python.org/en/latest/discussions/src-layout-vs-flat-layout/
- Documentación de pytest: https://docs.pytest.org/
- Documentación de uv: https://docs.astral.sh/uv/
- PEP 8 – Style Guide for Python Code: https://peps.python.org/pep-0008/
- PEP 257 – Docstring Conventions: https://peps.python.org/pep-0257/
- PEP 810 – Explicit lazy imports: https://peps.python.org/pep-0810/
- PEP 686 – Make UTF-8 mode default: https://peps.python.org/pep-0686/
Fin del Módulo 22 y del curso. Has completado el recorrido desde los fundamentos del pensamiento computacional hasta la construcción de un proyecto profesional. El siguiente paso es tuyo: elige un problema que te interese, aplica el proceso que has aprendido y sigue construyendo. La programación se aprende programando.
