trubidata
Lección 12 de 12·30 min

Proyecto final: app de consola

En esta guía (13)

En esta lección vas a construir una aplicación completa: un gestor de gastos personales que se usa desde la terminal. Vas a poder cargar gastos, listarlos, ver un resumen por categoría de cualquier mes y eliminar los que cargaste mal, y todo queda guardado en un archivo JSON entre una ejecución y la siguiente. No hay nada nuevo en sentido estricto: es el momento de combinar lo que aprendiste en las once lecciones anteriores.

Qué vamos a construir

Así se ve la app funcionando (los textos después de cada : son lo que escribe el usuario):

text
=== Gestor de gastos ===
1. Agregar gasto
2. Listar gastos
3. Resumen de un mes
4. Eliminar gasto
5. Salir
Opción: 1
Descripción: Carga SUBE
Monto: mucho
  Ingresá un número, por ejemplo 1500 o 1500.50
Monto: 5000
Categoría:
  1. comida
  2. transporte
  3. servicios
  4. ocio
  5. salud
  6. otros
Elegí una opción: 2
Fecha (AAAA-MM-DD, Enter = hoy): 2026-10-02
Gasto guardado.

Después de cargar algunos gastos, la opción 2 lista todo:

text
  #  Fecha       Categoría    Descripción                  Monto
----------------------------------------------------------------
  1  2026-09-28  servicios    Luz                      21,300.00
  2  2026-10-01  comida       Supermercado             18,500.50
  3  2026-10-02  transporte   Carga SUBE                5,000.00
  4  2026-10-04  ocio         Cine                      6,200.00
----------------------------------------------------------------
Total                                                  51,000.50

Y la opción 3 resume un mes:

text
Mes (AAAA-MM, Enter = mes actual): 2026-10

Resumen de 2026-10 (3 gastos)
  comida        18,500.50   62.3%
  ocio           6,200.00   20.9%
  transporte     5,000.00   16.8%
  Total         29,700.50

Requisitos

  • Cada gasto tiene descripción, monto, categoría (de una lista fija) y fecha.
  • Ningún dato inválido puede cortar el programa: si el usuario escribe mal, se le vuelve a pedir.
  • Los gastos se guardan en gastos.json cada vez que algo cambia, para no perder nada si se cierra la terminal.
  • Si el archivo está dañado, la app avisa y no lo pisa.
  • Solo biblioteca estándar: nada que instalar.

Cómo se organiza el código

Vamos a separar el programa en cuatro módulos, cada uno con una responsabilidad:

text
gastos/
├── modelos.py         qué es un gasto y cómo se maneja la lista de gastos
├── almacenamiento.py  guardar y cargar en JSON
├── entrada.py         pedir datos al usuario, validando
├── app.py             el menú: une todo lo anterior
└── gastos.json        se crea solo la primera vez que guardás

¿Por qué no un solo archivo? Porque así cada parte se entiende, se prueba y se cambia sin tocar las demás. Por ejemplo, modelos.py no sabe nada de la terminal ni de archivos: el día que quieras guardar en otro formato, solo cambiás almacenamiento.py. Es la idea de funciones chicas de la lección 7, aplicada a módulos.

Creá una carpeta gastos y, adentro, los cuatro archivos .py que siguen.

Paso 1: el modelo

modelos.py define el Gasto como dataclass (lección 11) y la clase Registro, que administra la lista de gastos.

python
# archivo: modelos.py
"""Modelos de la app: qué es un gasto y cómo se maneja la lista de gastos."""

from dataclasses import dataclass
from datetime import date

CATEGORIAS = ["comida", "transporte", "servicios", "ocio", "salud", "otros"]


@dataclass
class Gasto:
    descripcion: str
    monto: float
    categoria: str
    fecha: date

    def __post_init__(self) -> None:
        self.descripcion = self.descripcion.strip()
        if not self.descripcion:
            raise ValueError("La descripción no puede estar vacía")
        if self.monto <= 0:
            raise ValueError("El monto tiene que ser positivo")
        if self.categoria not in CATEGORIAS:
            raise ValueError(f"Categoría desconocida: {self.categoria}")

    def a_dict(self) -> dict:
        """Convierte el gasto a un diccionario que se puede guardar en JSON."""
        return {
            "descripcion": self.descripcion,
            "monto": self.monto,
            "categoria": self.categoria,
            "fecha": self.fecha.isoformat(),
        }


def gasto_desde_dict(datos: dict) -> Gasto:
    """Crea un Gasto a partir de un diccionario leído de JSON."""
    return Gasto(
        descripcion=datos["descripcion"],
        monto=float(datos["monto"]),
        categoria=datos["categoria"],
        fecha=date.fromisoformat(datos["fecha"]),
    )


class Registro:
    """La lista de gastos, siempre ordenada por fecha."""

    def __init__(self, gastos: list[Gasto] | None = None):
        if gastos is None:
            gastos = []
        self._gastos = sorted(gastos, key=lambda g: g.fecha)

    def __len__(self) -> int:
        return len(self._gastos)

    def agregar(self, gasto: Gasto) -> None:
        self._gastos.append(gasto)
        self._gastos.sort(key=lambda g: g.fecha)

    def eliminar(self, indice: int) -> Gasto:
        """Quita y devuelve el gasto en esa posición (empezando en 0)."""
        if not 0 <= indice < len(self._gastos):
            raise IndexError("No hay un gasto con ese número")
        return self._gastos.pop(indice)

    def todos(self) -> list[Gasto]:
        return self._gastos.copy()

    def del_mes(self, anio: int, mes: int) -> list[Gasto]:
        return [g for g in self._gastos if g.fecha.year == anio and g.fecha.month == mes]


def total(gastos: list[Gasto]) -> float:
    return sum(g.monto for g in gastos)


def total_por_categoria(gastos: list[Gasto]) -> dict[str, float]:
    """Suma los montos por categoría, de mayor a menor."""
    totales: dict[str, float] = {}
    for gasto in gastos:
        totales[gasto.categoria] = totales.get(gasto.categoria, 0) + gasto.monto
    return dict(sorted(totales.items(), key=lambda par: par[1], reverse=True))

Qué hay que mirar:

  • Gasto se valida solo. __post_init__ limpia la descripción y lanza ValueError si algún dato no tiene sentido. Así es imposible tener un Gasto inválido en cualquier parte del programa, venga del teclado o del archivo.
  • La fecha es un date, no un string. Eso permite ordenar y filtrar por año y mes sin parsear texto. Como JSON no sabe guardar fechas (lección 10), a_dict la convierte a texto con isoformat() y gasto_desde_dict hace el camino inverso con date.fromisoformat.
  • Registro encapsula la lista (_gastos, con guion bajo). El resto del programa no la toca directamente: usa agregar, eliminar, todos y del_mes. todos() devuelve una copia, por la misma razón que el historial de la cuenta en la lección 11.
  • La lista se mantiene ordenada por fecha con sort(key=lambda g: g.fecha): el key con lambda es el mismo recurso que usaste en la lección 10 para ordenar por total.
  • eliminar lanza IndexError si el número no existe, en vez de fallar en silencio.
  • total y total_por_categoria son funciones puras: reciben una lista y devuelven un resultado. Sirven tanto para todos los gastos como para los de un mes. dict(sorted(...)) convierte la lista ordenada de pares de vuelta en un diccionario, que conserva ese orden.
  • list[Gasto] | None = None: el valor por defecto es None y no [], por la trampa de los valores por defecto mutables (lección 7).

Paso 2: guardar y cargar

almacenamiento.py traduce entre la lista de Gasto y el archivo JSON.

python
# archivo: almacenamiento.py
"""Guardar y cargar los gastos en un archivo JSON."""

import json
from pathlib import Path

from modelos import Gasto, gasto_desde_dict


class ArchivoDanadoError(Exception):
    """El archivo de datos existe pero no se puede leer."""


def cargar(ruta: Path) -> list[Gasto]:
    """Lee los gastos del archivo. Si no existe, devuelve una lista vacía."""
    if not ruta.exists():
        return []
    try:
        with open(ruta, encoding="utf-8") as archivo:
            datos = json.load(archivo)
        return [gasto_desde_dict(d) for d in datos]
    except (json.JSONDecodeError, KeyError, ValueError) as e:
        raise ArchivoDanadoError(f"No pude leer {ruta.name}: {e}")


def guardar(ruta: Path, gastos: list[Gasto]) -> None:
    """Escribe todos los gastos en el archivo, sin dejarlo a medio escribir."""
    temporal = ruta.with_suffix(".tmp")
    with open(temporal, "w", encoding="utf-8") as archivo:
        json.dump([g.a_dict() for g in gastos], archivo, indent=2, ensure_ascii=False)
    temporal.replace(ruta)
  • Si el archivo no existe, es la primera ejecución: se arranca con una lista vacía.
  • Si existe pero está dañado (JSON mal formado, falta un campo, un monto negativo), se lanza una excepción propia, ArchivoDanadoError, en vez de devolver una lista vacía. Si devolviera una lista vacía, el próximo guardado pisaría el archivo y perderías todos tus gastos. Mejor frenar y avisar.
  • Se atrapan errores específicos: json.JSONDecodeError (formato), KeyError (falta un campo) y ValueError (un dato inválido, como una fecha mal escrita o el error que lanza __post_init__). Nunca un except: pelado.
  • El guardado es "atómico": primero se escribe un archivo temporal (gastos.tmp) y recién cuando está completo se lo renombra sobre gastos.json con Path.replace. Si algo falla a mitad de la escritura, el archivo original queda intacto. Es un truco simple que vale la pena conocer.

Paso 3: pedir datos

entrada.py reúne todas las funciones que hablan con el usuario. Todas siguen el patrón while True + try + return de la lección 8.

python
# archivo: entrada.py
"""Funciones para pedir datos al usuario, validando hasta que sean correctos."""

from datetime import date


def pedir_texto(mensaje: str) -> str:
    while True:
        texto = input(mensaje).strip()
        if texto:
            return texto
        print("  No puede quedar vacío.")


def pedir_monto(mensaje: str) -> float:
    while True:
        texto = input(mensaje).strip().replace(",", ".")
        try:
            monto = float(texto)
        except ValueError:
            print("  Ingresá un número, por ejemplo 1500 o 1500.50")
            continue
        if monto > 0:
            return monto
        print("  El monto tiene que ser mayor a cero.")


def pedir_entero(mensaje: str, minimo: int, maximo: int) -> int:
    while True:
        texto = input(mensaje).strip()
        try:
            numero = int(texto)
        except ValueError:
            print("  Tiene que ser un número entero.")
            continue
        if minimo <= numero <= maximo:
            return numero
        print(f"  Tiene que estar entre {minimo} y {maximo}.")


def pedir_opcion(mensaje: str, opciones: list[str]) -> str:
    """Muestra las opciones numeradas y devuelve la elegida."""
    for i, opcion in enumerate(opciones, start=1):
        print(f"  {i}. {opcion}")
    numero = pedir_entero(mensaje, 1, len(opciones))
    return opciones[numero - 1]


def pedir_fecha(mensaje: str) -> date:
    """Pide una fecha AAAA-MM-DD. Enter solo devuelve la fecha de hoy."""
    while True:
        texto = input(mensaje).strip()
        if not texto:
            return date.today()
        try:
            return date.fromisoformat(texto)
        except ValueError:
            print("  Usá el formato AAAA-MM-DD, por ejemplo 2026-10-13")


def pedir_mes(mensaje: str) -> tuple[int, int]:
    """Pide un mes AAAA-MM. Enter solo devuelve el mes actual."""
    while True:
        texto = input(mensaje).strip()
        if not texto:
            hoy = date.today()
            return hoy.year, hoy.month
        try:
            primer_dia = date.fromisoformat(f"{texto}-01")
        except ValueError:
            print("  Usá el formato AAAA-MM, por ejemplo 2026-10")
            continue
        return primer_dia.year, primer_dia.month
  • pedir_entero es la misma función de la lección 8; pedir_opcion la reutiliza para elegir de una lista numerada.
  • pedir_monto acepta coma decimal, porque es lo que muchas personas escriben.
  • pedir_fecha y pedir_mes aceptan Enter vacío como "hoy" o "este mes". pedir_mes arma el primer día del mes (2026-10-01) y deja que date.fromisoformat haga la validación: así rechaza 2026-13 sin que tengas que escribir esa regla.

Paso 4: el menú

app.py es el punto de entrada. Importa de los otros tres módulos y arma el menú.

python
# archivo: app.py
"""Gestor de gastos personales: el programa principal con el menú."""

from pathlib import Path

from almacenamiento import ArchivoDanadoError, cargar, guardar
from entrada import pedir_entero, pedir_fecha, pedir_mes, pedir_monto, pedir_opcion, pedir_texto
from modelos import CATEGORIAS, Gasto, Registro, total, total_por_categoria

RUTA_DATOS = Path(__file__).parent / "gastos.json"

MENU = """
=== Gestor de gastos ===
1. Agregar gasto
2. Listar gastos
3. Resumen de un mes
4. Eliminar gasto
5. Salir"""


def mostrar_tabla(gastos: list[Gasto]) -> None:
    print(f"{'#':>3}  {'Fecha':<10}  {'Categoría':<11}  {'Descripción':<22}{'Monto':>12}")
    print("-" * 64)
    for i, g in enumerate(gastos, start=1):
        print(f"{i:>3}  {g.fecha.isoformat():<10}  {g.categoria:<11}  {g.descripcion:<22}{g.monto:>12,.2f}")
    print("-" * 64)
    print(f"{'Total':<52}{total(gastos):>12,.2f}")


def agregar(registro: Registro) -> None:
    descripcion = pedir_texto("Descripción: ")
    monto = pedir_monto("Monto: ")
    print("Categoría:")
    categoria = pedir_opcion("Elegí una opción: ", CATEGORIAS)
    fecha = pedir_fecha("Fecha (AAAA-MM-DD, Enter = hoy): ")
    registro.agregar(Gasto(descripcion, monto, categoria, fecha))
    guardar(RUTA_DATOS, registro.todos())
    print("Gasto guardado.")


def listar(registro: Registro) -> None:
    if len(registro) == 0:
        print("Todavía no cargaste gastos.")
        return
    mostrar_tabla(registro.todos())


def resumen(registro: Registro) -> None:
    anio, mes = pedir_mes("Mes (AAAA-MM, Enter = mes actual): ")
    gastos = registro.del_mes(anio, mes)
    if not gastos:
        print(f"No hay gastos en {anio}-{mes:02d}.")
        return
    total_mes = total(gastos)
    print(f"\nResumen de {anio}-{mes:02d} ({len(gastos)} gastos)")
    for categoria, monto in total_por_categoria(gastos).items():
        print(f"  {categoria:<11}{monto:>12,.2f}  {monto / total_mes:>6.1%}")
    print(f"  {'Total':<11}{total_mes:>12,.2f}")


def eliminar(registro: Registro) -> None:
    if len(registro) == 0:
        print("No hay gastos para eliminar.")
        return
    mostrar_tabla(registro.todos())
    numero = pedir_entero("Número de gasto a eliminar (0 = cancelar): ", 0, len(registro))
    if numero == 0:
        print("Cancelado.")
        return
    quitado = registro.eliminar(numero - 1)
    guardar(RUTA_DATOS, registro.todos())
    print(f"Eliminado: {quitado.descripcion} ({quitado.monto:,.2f})")


def main() -> None:
    try:
        registro = Registro(cargar(RUTA_DATOS))
    except ArchivoDanadoError as e:
        print(e)
        print("Revisá el archivo o renombralo para empezar de cero. No se modificó nada.")
        return

    acciones = {"1": agregar, "2": listar, "3": resumen, "4": eliminar}
    while True:
        print(MENU)
        opcion = input("Opción: ").strip()
        if opcion == "5":
            print("Hasta luego.")
            break
        accion = acciones.get(opcion)
        if accion is None:
            print("Opción inválida.")
            continue
        accion(registro)


if __name__ == "__main__":
    main()
  • RUTA_DATOS se arma desde Path(__file__).parent (lección 10): el JSON queda al lado de app.py, sin importar desde qué carpeta ejecutes el programa.
  • Cada opción del menú es una función (agregar, listar, resumen, eliminar) que recibe el registro. main solo decide cuál llamar.
  • Las funciones también son valores. En acciones = {"1": agregar, ...} se guardan las funciones mismas (sin paréntesis, sin llamarlas) como valores de un diccionario. acciones.get(opcion) devuelve la función elegida, o None si la opción no existe, y accion(registro) la llama. Es una alternativa prolija a una cadena larga de if/elif.
  • Se guarda después de cada cambio, no solo al salir. Si cerrás la terminal de golpe, no perdés lo cargado.
  • if __name__ == "__main__": (lección 9) hace que main() corra solo cuando ejecutás app.py, y no si otro módulo lo importa.

Cómo correrla

Desde la terminal, parado en la carpeta gastos:

bash
python3 app.py

O con uv:

bash
uv run app.py

Cargá algunos gastos, salí con la opción 5 y volvé a ejecutarla: los gastos siguen ahí. Abrí gastos.json con el editor para ver cómo quedaron guardados:

text
[
  {
    "descripcion": "Luz",
    "monto": 21300.0,
    "categoria": "servicios",
    "fecha": "2026-09-28"
  },
  ...
]

Para probar la protección de datos, hacé una copia de gastos.json, borrá la primera llave { del original y volvé a ejecutar: la app avisa que no puede leerlo y no modifica nada.

text
No pude leer gastos.json: Expecting ',' delimiter: line 3 column 18 (char 22)
Revisá el archivo o renombralo para empezar de cero. No se modificó nada.

El mensaje exacto depende de dónde esté el error dentro del archivo.

Qué usaste de cada lección

LecciónDónde aparece
2 · Tipos y conversiónfloat(...), int(...), f-strings con :,.2f y :.1%
3 · Condicionales y buclesel menú con while True y break, las validaciones
4 y 5 · Coleccionesla lista de gastos, el diccionario de totales y el de acciones
6 · Stringsstrip, replace(",", "."), la tabla alineada
7 · Funcionesuna función por tarea, funciones puras, type hints, docstrings
8 · Errorestry/except específicos, raise, una excepción propia
9 · Móduloscuatro módulos, import, if __name__ == "__main__":, datetime, pathlib
10 · ArchivosJSON con dump/load, Path(__file__).parent, guardado atómico
11 · Objetosdataclass con __post_init__, la clase Registro con encapsulamiento

Practicá

Estas mejoras son para que sigas solo. No tienen una única solución: hacelas en orden, de a una, y probá la app después de cada cambio.

  1. Editar un gasto. Agregá una opción que permita corregir el monto de un gasto elegido por número. Pista: un método Registro.cambiar_monto(indice, nuevo_monto).
  2. Buscar. Agregá una opción que pida un texto y liste los gastos cuya descripción lo contenga, sin importar mayúsculas.
  3. Exportar a CSV. Agregá una opción que exporte todos los gastos a gastos.csv con csv.DictWriter, reutilizando a_dict().
  4. Presupuesto. Guardá un límite mensual por categoría en un segundo archivo JSON (presupuesto.json) y, en el resumen, mostrá al lado de cada categoría cuánto queda o en cuánto se pasó.

Soluciones

Ejercicio 1, el método en Registro y la opción en app.py:

python
# en modelos.py, dentro de la clase Registro
    def cambiar_monto(self, indice: int, nuevo_monto: float) -> None:
        if not 0 <= indice < len(self._gastos):
            raise IndexError("No hay un gasto con ese número")
        if nuevo_monto <= 0:
            raise ValueError("El monto tiene que ser positivo")
        self._gastos[indice].monto = nuevo_monto
python
# en app.py
def editar(registro: Registro) -> None:
    if len(registro) == 0:
        print("No hay gastos para editar.")
        return
    mostrar_tabla(registro.todos())
    numero = pedir_entero("Número de gasto a editar (0 = cancelar): ", 0, len(registro))
    if numero == 0:
        return
    nuevo = pedir_monto("Nuevo monto: ")
    registro.cambiar_monto(numero - 1, nuevo)
    guardar(RUTA_DATOS, registro.todos())
    print("Monto actualizado.")

Después, sumá la opción al texto de MENU y al diccionario acciones (por ejemplo, como "5", y pasá "Salir" al "6"). Fijate que cambiar_monto valida el monto aunque pedir_monto ya lo haga: el modelo no debería confiar en que quien lo llama validó bien.

Ejercicio 2:

python
# en app.py
def buscar(registro: Registro) -> None:
    texto = pedir_texto("Buscar: ").lower()
    encontrados = [g for g in registro.todos() if texto in g.descripcion.lower()]
    if not encontrados:
        print("No hay coincidencias.")
        return
    mostrar_tabla(encontrados)

mostrar_tabla recibe cualquier lista de gastos, así que se reutiliza tal cual. (Ojo: los números de esa tabla son posiciones dentro de los resultados, no del registro completo.)

Ejercicio 3:

python
# en almacenamiento.py
import csv


def exportar_csv(ruta: Path, gastos: list[Gasto]) -> None:
    with open(ruta, "w", encoding="utf-8", newline="") as archivo:
        escritor = csv.DictWriter(archivo, fieldnames=["fecha", "categoria", "descripcion", "monto"])
        escritor.writeheader()
        for gasto in gastos:
            escritor.writerow(gasto.a_dict())

DictWriter ordena las columnas según fieldnames, así que no importa el orden de las claves en a_dict(). El import csv va arriba del archivo, junto a los otros imports.

Ejercicio 4: una forma es cargar el presupuesto al empezar (json.load de un diccionario como {"comida": 60000, "ocio": 20000}) y, en resumen, calcular restante = presupuesto.get(categoria) para cada categoría. Si es None, no mostrás nada; si no, mostrás restante - monto, con un texto distinto si el resultado es negativo. Probá primero escribiendo el JSON a mano antes de agregar una opción de menú para editarlo.

Próximos pasos

Terminaste el curso: ya podés escribir programas de verdad en Python. Algunos caminos para seguir:

  • Git y GitHub. Poné tu proyecto bajo control de versiones: vas a poder volver atrás, ver qué cambiaste y publicarlo como parte de tu portfolio. Empezá por la guía de Git. Agregá gastos.json y .venv a un .gitignore: tus datos personales no van al repositorio.
  • Tests con pytest. En vez de probar a mano cada vez, escribí funciones que verifiquen tu código automáticamente. Las funciones puras como total_por_categoria y las validaciones de Gasto son ideales para empezar. Instalá pytest con uv add --dev pytest y escribí un test_modelos.py con funciones test_... que usen assert.
  • Leer código ajeno. Elegí una librería de la biblioteca estándar que hayas usado (por ejemplo csv) y leé su documentación completa. Vas a descubrir opciones que no conocías.
  • Datos. Si te interesa trabajar con datos, el siguiente paso natural es aplicar Python a procesar archivos grandes, bases de datos y pipelines:

Resumen

  • Una app chica ya se beneficia de separar responsabilidades: modelo, almacenamiento, entrada y menú en módulos distintos.
  • Validar en el modelo (__post_init__) garantiza que no existan datos inválidos en ninguna parte del programa.
  • Las funciones de entrada con while True + try evitan que un error de tipeo corte la app.
  • Guardar después de cada cambio, con escritura atómica, y negarse a pisar un archivo dañado protegen los datos del usuario.
  • Las funciones son valores: se pueden guardar en un diccionario y llamar después.
  • Lo que sigue: Git para versionar, pytest para probar automáticamente, y, si te interesan los datos, las guías y el curso de Scala del sitio.

← Volver al temario