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):
=== 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:
# 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:
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.jsoncada 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:
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.
# 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:
Gastose valida solo.__post_init__limpia la descripción y lanzaValueErrorsi algún dato no tiene sentido. Así es imposible tener unGastoinvá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_dictla convierte a texto conisoformat()ygasto_desde_dicthace el camino inverso condate.fromisoformat. Registroencapsula la lista (_gastos, con guion bajo). El resto del programa no la toca directamente: usaagregar,eliminar,todosydel_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): elkeyconlambdaes el mismo recurso que usaste en la lección 10 para ordenar por total. eliminarlanzaIndexErrorsi el número no existe, en vez de fallar en silencio.totalytotal_por_categoriason 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 esNoney 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.
# 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) yValueError(un dato inválido, como una fecha mal escrita o el error que lanza__post_init__). Nunca unexcept:pelado. - El guardado es "atómico": primero se escribe un archivo temporal (
gastos.tmp) y recién cuando está completo se lo renombra sobregastos.jsonconPath.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.
# 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_enteroes la misma función de la lección 8;pedir_opcionla reutiliza para elegir de una lista numerada.pedir_montoacepta coma decimal, porque es lo que muchas personas escriben.pedir_fechaypedir_mesaceptan Enter vacío como "hoy" o "este mes".pedir_mesarma el primer día del mes (2026-10-01) y deja quedate.fromisoformathaga la validación: así rechaza2026-13sin 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ú.
# 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_DATOSse arma desdePath(__file__).parent(lección 10): el JSON queda al lado deapp.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.mainsolo 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, oNonesi la opción no existe, yaccion(registro)la llama. Es una alternativa prolija a una cadena larga deif/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 quemain()corra solo cuando ejecutásapp.py, y no si otro módulo lo importa.
Cómo correrla
Desde la terminal, parado en la carpeta gastos:
python3 app.py
O con uv:
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:
[
{
"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.
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ón | Dónde aparece |
|---|---|
| 2 · Tipos y conversión | float(...), int(...), f-strings con :,.2f y :.1% |
| 3 · Condicionales y bucles | el menú con while True y break, las validaciones |
| 4 y 5 · Colecciones | la lista de gastos, el diccionario de totales y el de acciones |
| 6 · Strings | strip, replace(",", "."), la tabla alineada |
| 7 · Funciones | una función por tarea, funciones puras, type hints, docstrings |
| 8 · Errores | try/except específicos, raise, una excepción propia |
| 9 · Módulos | cuatro módulos, import, if __name__ == "__main__":, datetime, pathlib |
| 10 · Archivos | JSON con dump/load, Path(__file__).parent, guardado atómico |
| 11 · Objetos | dataclass 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.
- 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). - Buscar. Agregá una opción que pida un texto y liste los gastos cuya descripción lo contenga, sin importar mayúsculas.
- Exportar a CSV. Agregá una opción que exporte todos los gastos a
gastos.csvconcsv.DictWriter, reutilizandoa_dict(). - 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:
# 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
# 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:
# 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:
# 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.jsony.venva 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_categoriay las validaciones deGastoson ideales para empezar. Instalá pytest conuv add --dev pytesty escribí untest_modelos.pycon funcionestest_...que usenassert. - 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:
- Guía de Python para Data Engineering
- uv y entornos virtuales
- SQL desde cero
- Roadmap de Ingeniería de Datos
- Y si querés un segundo lenguaje con foco en datos, el curso Scala para Data Engineering.
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+tryevitan 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.