Herramientas de usuario

Herramientas del sitio


informatica:programacion:cursos:programacion_desde_cero_con_python:proyecto_final_integrador

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):

IDRequisitoCriterio de aceptación
RF1Añadir un gasto con fecha, concepto, categoría e importe.El gasto se guarda y aparece en el listado.
RF2Listar todos los gastos, opcionalmente filtrados por categoría.Se muestran los gastos ordenados por fecha.
RF3Calcular el total gastado y el total por categoría.Los totales coinciden con la suma manual.
RF4Eliminar un gasto por su identificador.El gasto desaparece y el total se actualiza.
RF5Persistir 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ño src/ (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.toml es la fuente única de verdad del proyecto (Módulo 19). La tabla [project.scripts] crea un comando gestor-gastos que ejecuta main() de cli.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 usar frozendict para el resultado de total_por_categoria si 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] Guardar date directamente en JSON lanza TypeError. Hay que convertirlo a cadena con .isoformat() y reconstruirlo con date.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] argparse con 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 con tmp_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. Ejecuta pytest despué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. pathlib y UTF-8 explícito eliminan la mayoría de las diferencias.

6. Ejemplo de evaluación y presentación

Criterios de evaluación sugeridos:

CriterioPesoQué se evalúa
Requisitos y diseño20 %Claridad de la especificación y justificación de la arquitectura.
Funcionalidad25 %Todos los RF cumplen sus criterios de aceptación.
Calidad del código20 %Nombres, responsabilidades, DRY, KISS, Ruff limpio.
Pruebas15 %Cobertura de casos normales, límite e inválidos; pytest pasa.
Documentación10 %Docstrings, README y comentarios útiles.
Portabilidad y Git10 %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

ErrorCausaCómo evitarlo
Mezclar lógica y presentaciónprint() dentro de funciones de cálculo.Separa dominio y CLI.
Guardar date en JSON sin convertirTypeError al serializar.Usa .isoformat() y date.fromisoformat().
Pruebas que dependen del directorio personalUso de Path.home() en pruebas.Usa tmp_path de pytest.
No fijar dependencias de desarrollopyproject.toml incompleto.Declara dev en [project.optional-dependencies].
Commits gigantesTodo el proyecto en un commit.Commits pequeños y temáticos.
No probar en ambos sistemasAsumir 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.

informatica/programacion/cursos/programacion_desde_cero_con_python/proyecto_final_integrador.txt · Última modificación: por tempwin