Módulo 2 · Lección 27 de 28
Proyecto: gestor de gastos
Una herramienta de gastos personales con subcomandos, almacenamiento en CSV, dinero exacto con Decimal y resumen con gráfico de barras.
En la lección de números del módulo anterior quedó una promesa pendiente. Decía así: "si tu programa maneja euros y céntimos, los errores de redondeo se acumulan y acaban descuadrando cuentas. Lo veremos en el módulo de aplicaciones de terminal, cuando construyamos una herramienta de gastos".
Esta es esa herramienta, y es el proyecto central del módulo. Tiene subcomandos como git, guarda los datos en un CSV que puedes abrir con una hoja de cálculo, se configura con un fichero JSON, saca tablas en color y dibuja un gráfico de barras en la terminal. Y maneja el dinero como hay que manejarlo.
Cómo se va a usar#
La herramienta terminada se maneja así:
gastos añadir 12,50 comida "Menú del día"
gastos listar --mes 2026-09 --categoria comida
gastos resumen --mes 2026-09
gastos borrar 6
gastos exportar gastos-septiembre.json --mes 2026-09Y la vista que justifica el proyecto entero es esta:
Resumen de 2026-09
CATEGORÍA IMPORTE % GRÁFICO
--------------------------------------------------------------------
hogar 129.99 € 42.1% ████████████████████████████████
ocio 81.99 € 26.5% ████████████████████▏
transporte 49.30 € 15.9% ████████████▏
comida 47.85 € 15.5% ███████████▊
--------------------------------------------------------------------
TOTAL 309.13 €En la terminal, cada categoría sale de un color y los totales en negrita. Aquí se ve en blanco y negro porque la salida está capturada a un fichero, y ya veremos que eso el programa lo detecta solo.
Antes de nada: por qué no se puede usar float#
Un gasto es un número con dos decimales. La tentación es guardarlo en un float y seguir adelante. Mira lo que pasa:
from decimal import Decimal
gastos_float = [12.50, 8.20, 27.15, 54.30, 0.1, 0.2]
gastos_decimal = [Decimal("12.50"), Decimal("8.20"), Decimal("27.15"),
Decimal("54.30"), Decimal("0.1"), Decimal("0.2")]
print(sum(gastos_float))
print(sum(gastos_decimal))
print(sum(gastos_float) == 102.45)
print(sum(gastos_decimal) == Decimal("102.45"))
print(Decimal("0.1") + Decimal("0.2"))
print(Decimal(0.1))102.44999999999999
102.45
False
True
0.3
0.1000000000000000055511151231257827021181583404541015625Seis gastos han bastado para descuadrar la suma. No es un fallo de Python: los float se guardan en binario, y 0.1 en binario es periódico igual que un tercio en decimal, así que lo que se guarda es casi 0.1. La última línea lo enseña sin maquillaje.
Un céntimo de nada en una suma no importa. Importa cuando ese resultado se compara con == para cuadrar un arqueo, cuando se acumulan diez mil líneas, o cuando alguien mira el total y no le sale.
El módulo decimal guarda los números en base diez, como los escribimos. Tres reglas y ya lo sabes usar:
- Se construye desde una cadena, no desde un
float.Decimal("0.1")es exactamente una décima;Decimal(0.1)arrastra la basura delfloat, como se ve en la última línea del ejemplo. - Suma, resta y multiplica exacto. La división puede dar decimales infinitos, y ahí sí hay que redondear.
quantizefija los decimales.importe.quantize(Decimal("0.01"))deja dos, que es lo que tiene el euro.
El diseño#
Seis módulos, partidos por la línea que ya conoces: los que saben de dinero y los que saben de pantalla.
gastos/
pyproject.toml Metadatos y el comando gastos
src/
gastos/
__init__.py
modelo.py La dataclass Gasto y las conversiones desde texto
almacen.py Leer y escribir el CSV
configuracion.py Moneda y ruta de los datos
informes.py Totales por categoría y barras
presentacion.py Colores y tablas
cli.py Argumentos, subcomandos y códigos de salida
__main__.py Permite python3 -m gastos| Módulo | De qué se encarga | ¿Imprime? |
|---|---|---|
modelo.py | Qué es un gasto y cómo se valida | No |
almacen.py | El CSV: cargar, guardar, filtrar, borrar | No |
configuracion.py | De dónde salen la moneda y la ruta | No |
informes.py | Los cálculos del resumen | No |
presentacion.py | Colores, tablas y gráficos | Sí |
cli.py | Traducir la línea de comandos en llamadas | Sí |
Las cuatro primeras filas no saben que existe una terminal. Podrías poner este programa detrás de una web sin tocarlas.
Parte 1: el modelo#
El dato central, y las conversiones desde texto, que es como llega todo desde la terminal.
"""El dato central del programa: un gasto, y cómo se convierte desde texto."""
from dataclasses import dataclass
from datetime import date
from decimal import Decimal, InvalidOperation
CENTIMOS = Decimal("0.01")
@dataclass
class Gasto:
"""Un gasto apuntado: cuándo, en qué, cuánto."""
identificador: int
fecha: date
categoria: str
concepto: str
importe: Decimal
@property
def mes(self) -> str:
"""El mes del gasto en formato aaaa-mm, que es como se filtra."""
return self.fecha.strftime("%Y-%m")
def __str__(self) -> str:
return f"[{self.identificador}] {self.fecha} {self.categoria}: {self.concepto} ({self.importe})"
def a_importe(texto: str) -> Decimal:
"""Convierte texto en un importe exacto de dos decimales.
Acepta la coma decimal española y el símbolo del euro.
"""
limpio = texto.strip().replace("€", "").replace(" ", "").replace(",", ".")
try:
importe = Decimal(limpio)
except InvalidOperation:
raise ValueError(f"'{texto}' no es un importe") from None
if importe <= 0:
raise ValueError("el importe tiene que ser mayor que cero")
if importe.as_tuple().exponent < -2:
raise ValueError("el importe no puede tener más de dos decimales")
return importe.quantize(CENTIMOS)
def a_fecha(texto: str) -> date:
"""Convierte texto en fecha. Solo admite el formato aaaa-mm-dd."""
try:
return date.fromisoformat(texto)
except ValueError:
raise ValueError(f"'{texto}' no es una fecha aaaa-mm-dd") from None
def a_mes(texto: str) -> str:
"""Valida un mes en formato aaaa-mm y lo devuelve tal cual."""
try:
date.fromisoformat(f"{texto}-01")
except ValueError:
raise ValueError(f"'{texto}' no es un mes aaaa-mm") from None
return texto
def a_categoria(texto: str) -> str:
"""Normaliza la categoría: sin espacios sobrantes y en minúsculas."""
categoria = texto.strip().lower()
if not categoria:
raise ValueError("la categoría no puede estar vacía")
return categoriaLas decisiones:
- La fecha es un
datede verdad, no una cadena como en el gestor de tareas del módulo anterior. Allí se guardaba como texto porque JSON no sabe escribir fechas; aquí el CSV tampoco, pero el programa necesita agrupar por mes y comparar, así que se convierte al leer y al escribir, y dentro del programa siempre es un objeto. meses una propiedad que calcula"2026-09"a partir de la fecha. Es el dato por el que se filtra, y deducirlo evita guardarlo dos veces.- Las cuatro funciones de conversión viven aquí y no en
cli.py. Son reglas del negocio: qué es un importe válido no depende de si el dato entró por teclado, por un fichero o por una web. Que estén en el modelo permite quealmacen.pylas use también al leer el CSV, y así los datos se validan siempre igual. - Se acepta la coma decimal. Quien escribe en español teclea
12,50sin pensar. Rechazarlo sería tener razón y perder al usuario. También se quita el€y los espacios, por si alguien copia y pega. - Todas lanzan
ValueError, que es la excepción estándar de "este valor no vale". Ninguna imprime nada ni sabe nada deargparse. raise ... from Nonecorta el encadenado de excepciones. Sin él, el mensaje que ve quien usa el programa vendría precedido de un "During handling of the above exception, another exception occurred" y delInvalidOperationoriginal, que no le dice nada a nadie.as_tuple().exponentda el número de decimales. Sin esa comprobación,12.999se redondearía a13.00en silencio, y redondear el dinero de otro sin avisar no es aceptable.
Parte 2: el almacén#
Aquí se decide el formato del fichero. Un CSV, por tres razones: se abre con cualquier hoja de cálculo, se puede mirar y corregir con un editor de texto, y crece bien cuando hay miles de líneas.
"""Guardar y recuperar los gastos en un CSV. No sabe nada de la pantalla."""
import csv
from decimal import Decimal
from pathlib import Path
from .modelo import CENTIMOS, Gasto, a_fecha, a_importe
CAMPOS = ["identificador", "fecha", "categoria", "concepto", "importe"]
class ErrorDeAlmacen(Exception):
"""El fichero de datos existe pero no se puede usar."""
class Almacen:
"""La lista de gastos en memoria y su fichero CSV en disco."""
def __init__(self, ruta: Path):
self.ruta = Path(ruta)
self.gastos: list[Gasto] = []
def __len__(self) -> int:
return len(self.gastos)
def cargar(self) -> None:
"""Lee el CSV. Si no existe, se empieza con la lista vacía."""
if not self.ruta.exists():
self.gastos = []
return
gastos = []
with open(self.ruta, encoding="utf-8", newline="") as fichero:
for numero, fila in enumerate(csv.DictReader(fichero), start=2):
try:
gastos.append(Gasto(
identificador=int(fila["identificador"]),
fecha=a_fecha(fila["fecha"]),
categoria=fila["categoria"],
concepto=fila["concepto"],
importe=a_importe(fila["importe"]),
))
except (KeyError, TypeError, ValueError) as error:
raise ErrorDeAlmacen(
f"{self.ruta}, línea {numero}: {error}"
) from error
self.gastos = gastos
def guardar(self) -> None:
"""Escribe el CSV entero, primero en un temporal y luego encima."""
self.ruta.parent.mkdir(parents=True, exist_ok=True)
temporal = self.ruta.with_suffix(".csv.tmp")
with open(temporal, "w", encoding="utf-8", newline="") as fichero:
escritor = csv.DictWriter(fichero, fieldnames=CAMPOS)
escritor.writeheader()
for gasto in sorted(self.gastos, key=lambda gasto: (gasto.fecha, gasto.identificador)):
escritor.writerow({
"identificador": gasto.identificador,
"fecha": gasto.fecha.isoformat(),
"categoria": gasto.categoria,
"concepto": gasto.concepto,
"importe": f"{gasto.importe:.2f}",
})
temporal.replace(self.ruta)Las decisiones de estos dos métodos:
- El CSV se lee con
DictReadery se escribe conDictWriter, conencoding="utf-8"ynewline=""en los dos, tal como quedó dicho en la lección de CSV. Sinnewline="", un concepto con un salto de línea dentro rompe la lectura, y en Windows el fichero sale con una línea en blanco entre cada dos. - La numeración de líneas empieza en 2.
enumerate(..., start=2)cuenta la cabecera, así que cuando el programa dice "línea 7" es la línea 7 del fichero que vas a abrir con el editor. Decir "línea 6" y que no cuadre es la clase de detalle que hace perder diez minutos. - Un CSV corrupto lanza
ErrorDeAlmacen, una excepción propia. El almacén no puede decidir qué hacer ante un fichero ilegible: ¿avisar y seguir?, ¿parar?, ¿preguntar? Esa es una decisión de interfaz, así que el almacén informa y la capa de arriba decide. Y como es una clase propia,cli.pypuede capturarla sin capturar de paso cualquierValueErrorque venga de otro sitio. - El importe se escribe con
f"{gasto.importe:.2f}"y no constr(). UnDecimal("9")se imprimiría como9y en el fichero de dinero queremos9.00. - Se ordena al guardar, por fecha y en caso de empate por identificador. Un fichero de datos ordenado se lee a ojo y se compara con el de ayer.
- Y lo más importante: se escribe en un temporal y luego
replace. Escribir encima del fichero de datos es el momento más peligroso de todo el programa: si el disco se llena o se corta la luz a mitad, elopen(..., "w")ya ha vaciado tu fichero y lo que queda es medio CSV. Con un temporal, el original no se toca hasta el final, yPath.replace()hace el cambio en una sola operación del sistema de ficheros. O tienes el fichero viejo entero o el nuevo entero, nunca la mitad de ninguno.
El resto de la clase son las operaciones sobre la lista, sin ninguna sorpresa:
def siguiente_identificador(self) -> int:
if not self.gastos:
return 1
return max(gasto.identificador for gasto in self.gastos) + 1
def anadir(self, fecha, categoria: str, concepto: str, importe: Decimal) -> Gasto:
concepto = concepto.strip()
if not concepto:
raise ValueError("el concepto no puede estar vacío")
gasto = Gasto(self.siguiente_identificador(), fecha, categoria, concepto, importe)
self.gastos.append(gasto)
return gasto
def buscar(self, identificador: int) -> Gasto | None:
for gasto in self.gastos:
if gasto.identificador == identificador:
return gasto
return None
def borrar(self, identificador: int) -> Gasto | None:
gasto = self.buscar(identificador)
if gasto is not None:
self.gastos.remove(gasto)
return gasto
def filtrar(self, mes: str | None = None, categoria: str | None = None) -> list[Gasto]:
"""Devuelve los gastos que cumplen los filtros, ordenados por fecha."""
seleccion = self.gastos
if mes is not None:
seleccion = [gasto for gasto in seleccion if gasto.mes == mes]
if categoria is not None:
seleccion = [gasto for gasto in seleccion if gasto.categoria == categoria]
return sorted(seleccion, key=lambda gasto: (gasto.fecha, gasto.identificador))
def meses(self) -> list[str]:
return sorted({gasto.mes for gasto in self.gastos})
def total(gastos: list[Gasto]) -> Decimal:
"""Suma exacta de una lista de gastos."""
return sum((gasto.importe for gasto in gastos), Decimal("0")).quantize(CENTIMOS)Dos detalles que merecen la pena:
filtraracumula filtros conNonecomo "sin filtro". Cadaifreduce la selección, y llamarla sin argumentos devuelve todo ordenado. Es mucho más fácil de ampliar que una condición gigante, y cada filtro es una comprensión de una línea.sumlleva un segundo argumento,Decimal("0"). Sin él,sumempieza en el entero0y sumar unintcon unDecimalfunciona, pero con la lista vacía devolvería elint0en lugar de unDecimal, y el.quantizede después fallaría. El valor inicial de la suma marca el tipo del resultado.
Probando el almacén sin nada de interfaz:
from datetime import date
from pathlib import Path
from gastos.almacen import Almacen, total
from gastos.modelo import a_importe
almacen = Almacen(Path("prueba/demo.csv"))
almacen.cargar()
almacen.anadir(date(2026, 9, 1), "comida", "Menú del día", a_importe("12,50"))
almacen.anadir(date(2026, 9, 3), "comida", "Café y tostada", a_importe("8.20"))
almacen.anadir(date(2026, 8, 28), "ocio", "Cine", a_importe("9"))
almacen.guardar()
print(len(almacen))
print(total(almacen.gastos))
print([gasto.identificador for gasto in almacen.filtrar(mes="2026-09")])
print(almacen.meses())
otro = Almacen(Path("prueba/demo.csv"))
otro.cargar()
print(otro.filtrar() == almacen.filtrar())
print(Path("prueba/demo.csv").read_text(encoding="utf-8"))3
29.70
[1, 2]
['2026-08', '2026-09']
True
identificador,fecha,categoria,concepto,importe
3,2026-08-28,ocio,Cine,9.00
1,2026-09-01,comida,Menú del día,12.50
2,2026-09-03,comida,Café y tostada,8.20El True de la penúltima línea es la prueba de que el viaje de ida y vuelta no ha perdido nada: se guardó, se leyó en un objeto nuevo y los gastos salen iguales, importes Decimal y fechas date incluidos. Funciona gracias al __eq__ que genera la dataclass. Se comparan los resultados de filtrar() y no las listas en bruto porque en memoria están en el orden en que se añadieron y en el fichero están ordenados por fecha.
Parte 3: la configuración#
Dos cosas tienen que poder cambiarse sin tocar el código: la moneda y dónde viven los datos.
"""De dónde salen la moneda y la ruta de los datos."""
import json
import os
from dataclasses import dataclass
from pathlib import Path
RUTA_CONFIGURACION_POR_DEFECTO = Path.home() / ".config" / "gastos" / "configuracion.json"
RUTA_DATOS_POR_DEFECTO = Path.home() / ".local" / "share" / "gastos" / "gastos.csv"
@dataclass
class Configuracion:
"""Los ajustes del programa, ya resueltos."""
moneda: str = "€"
datos: Path = RUTA_DATOS_POR_DEFECTO
ancho_barra: int = 32
def ruta_configuracion() -> Path:
"""La ruta del fichero de ajustes, que GASTOS_CONFIG puede cambiar."""
variable = os.environ.get("GASTOS_CONFIG")
if variable:
return Path(variable).expanduser()
return RUTA_CONFIGURACION_POR_DEFECTO
def cargar_configuracion() -> Configuracion:
"""Lee el fichero de ajustes si existe y rellena lo que falte por defecto."""
ruta = ruta_configuracion()
if not ruta.exists():
return Configuracion()
try:
datos = json.loads(ruta.read_text(encoding="utf-8"))
except json.JSONDecodeError as error:
raise ValueError(f"{ruta} no es un JSON válido: {error}") from error
if not isinstance(datos, dict):
raise ValueError(f"{ruta} debería contener un objeto JSON")
configuracion = Configuracion()
if "moneda" in datos:
configuracion.moneda = str(datos["moneda"])
if "datos" in datos:
configuracion.datos = Path(str(datos["datos"])).expanduser()
if "ancho_barra" in datos:
configuracion.ancho_barra = int(datos["ancho_barra"])
return configuracionEl fichero de ajustes tiene esta pinta:
{
"moneda": "€",
"datos": "~/Documentos/gastos.csv",
"ancho_barra": 28
}Hay tres niveles, y el orden importa:
| Nivel | Quién manda | Para qué |
|---|---|---|
| 1. Valores por defecto | La dataclass Configuracion | Que el programa funcione recién instalado, sin configurar nada |
| 2. Fichero de ajustes | ~/.config/gastos/configuracion.json | Tus preferencias, que no cambian |
3. Opción --datos | La línea de comandos | Esta vez y solo esta vez |
Cada nivel pisa al anterior, y es el orden que siguen casi todas las herramientas de terminal. Los detalles:
- La configuración es una dataclass con valores por defecto, no un diccionario. Así
configuracion.monedase completa solo en el editor y un error de escritura salta al momento, en vez de devolverNonedesde un.get("monneda"). - Falta el fichero, no pasa nada. La primera ejecución no tiene ajustes y no debe dar un error; devuelve los valores por defecto.
- Fichero ilegible sí es un error, y se lanza como
ValueErrorpara quecli.pylo cuente con su formato. Callarse un JSON roto significaría usar la ruta por defecto y que el usuario no encuentre sus gastos. - Cada clave se lee con un
ifpropio. Un fichero con solo{"moneda": "$"}es válido y las otras dos claves se quedan por defecto. Si se hubiera hechoConfiguracion(**datos), una clave desconocida tiraría el programa conTypeError. expanduser()convierte el~en la carpeta del usuario. Dentro de un JSON,~es un carácter más: nadie lo expande por ti.GASTOS_CONFIGcambia dónde se busca el fichero. Es lo que permite tener un juego de datos para las pruebas sin tocar los de verdad, y es lo que se usa en toda esta lección.
Parte 4: el resumen y las barras#
Los cálculos del informe, tres funciones cortas y ni un print:
"""Los cálculos del resumen: totales por categoría y barras de texto."""
from decimal import Decimal
from .modelo import CENTIMOS, Gasto
# El bloque lleno y los ocho anchos parciales, del más estrecho al lleno.
OCTAVOS = " ▏▎▍▌▋▊▉█"
def totales_por_categoria(gastos: list[Gasto]) -> list[tuple[str, Decimal]]:
"""Suma por categoría, de mayor a menor gasto."""
acumulado: dict[str, Decimal] = {}
for gasto in gastos:
acumulado[gasto.categoria] = acumulado.get(gasto.categoria, Decimal("0")) + gasto.importe
parejas = [(categoria, suma.quantize(CENTIMOS)) for categoria, suma in acumulado.items()]
return sorted(parejas, key=lambda par: (-par[1], par[0]))
def barra(valor: Decimal, maximo: Decimal, ancho: int) -> str:
"""Dibuja una barra proporcional con bloques, con resolución de un octavo."""
if maximo <= 0 or valor <= 0:
return ""
octavos = int(valor / maximo * ancho * 8)
octavos = max(octavos, 1)
enteros, resto = divmod(octavos, 8)
return OCTAVOS[8] * enteros + (OCTAVOS[resto] if resto else "")
def porcentaje(valor: Decimal, total: Decimal) -> Decimal:
"""El peso de un importe sobre el total, con un decimal."""
if total <= 0:
return Decimal("0.0")
return (valor / total * 100).quantize(Decimal("0.1"))from decimal import Decimal
from gastos.informes import barra
maximo = Decimal("62.00")
for valor in ["62.00", "45.90", "20.70", "3.00", "0.40"]:
print(f"{valor:>6} |{barra(Decimal(valor), maximo, 20)}|") 62.00 |████████████████████|
45.90 |██████████████▊|
20.70 |██████▋|
3.00 |▉|
0.40 |▏|Las decisiones:
- Los bloques parciales dan ocho veces más resolución. Unicode trae ocho caracteres de bloque de anchos crecientes, y con ellos una barra de veinte caracteres distingue ciento sesenta niveles en vez de veinte. Por eso
45.90no se confunde con48.00. - La cuenta se hace en octavos. Se calcula el ancho total en octavos,
divmodlo parte en bloques enteros y resto, y el resto elige el carácter parcial. La cadenaOCTAVOSempieza por un espacio para que el índice0sea "nada". max(octavos, 1)garantiza que un importe pequeño pero real deje una marca visible. Una barra vacía junto a un importe distinto de cero parece un fallo, como se ve en la última línea del ejemplo.- El orden es por importe descendente y, en caso de empate, alfabético. La clave
(-par[1], par[0])hace las dos cosas a la vez: negar el importe invierte el criterio numérico sin invertir el del texto. Es el truco que ya viste en la lección de ordenar. - El acumulado usa
.get(categoria, Decimal("0")). UnCounterno sirve aquí: está pensado para contar enteros, y sumarDecimalcon él da problemas en cuanto se mezcla con su cero implícito.
Parte 5: la presentación#
Esta capa es la única que imprime. Dos ideas nuevas.
"""Todo lo que se ve: colores, tablas y gráficos. Aquí sí se imprime."""
import os
import sys
from decimal import Decimal
from .informes import barra, porcentaje, totales_por_categoria
from .modelo import Gasto
NORMAL = "\033[0m"
NEGRITA = "\033[1m"
APAGADO = "\033[2m"
ROJO = "\033[31m"
# Colores que se reparten entre las categorías: cian, verde, amarillo, magenta, azul.
PALETA = ["\033[36m", "\033[32m", "\033[33m", "\033[35m", "\033[34m"]
ANCHO_CONCEPTO = 28
ANCHO_CATEGORIA = 12
def quiere_color(modo: str, flujo=sys.stdout) -> bool:
"""Decide si se pinta en color: por opción, por entorno o por destino."""
if modo == "nunca":
return False
if modo == "siempre":
return True
if os.environ.get("NO_COLOR"):
return False
return flujo.isatty()
class Pintor:
"""Envuelve texto en códigos ANSI, o no hace nada si el color está apagado."""
def __init__(self, activo: bool):
self.activo = activo
def pinta(self, texto: str, *codigos: str) -> str:
if not self.activo or not codigos:
return texto
return "".join(codigos) + texto + NORMAL
def color_de(self, categoria: str) -> str:
"""Un color estable para cada categoría, el mismo en todas las ejecuciones."""
return PALETA[sum(ord(letra) for letra in categoria) % len(PALETA)]La primera idea es el color se apaga solo cuando no hay nadie mirando. flujo.isatty() devuelve True si la salida va a una terminal y False si va a un fichero o a una tubería. Sin esa comprobación, esto dejaría basura:
gastos listar > gastos.txtEl fichero se llenaría de \033[36m y compañía, y peor todavía: encadenar la salida con grep fallaría porque las secuencias de escape se meten en medio de las palabras. Las tres condiciones de quiere_color son el estándar de hecho: la opción explícita manda sobre todo, la variable de entorno NO_COLOR la respetan cada vez más herramientas, y en último término se mira a dónde va la salida.
La segunda idea es el color de cada categoría tiene que ser el mismo siempre. La tentación es usar hash(categoria) % len(PALETA), y es un error clásico: Python aleatoriza el hash de las cadenas en cada arranque del intérprete por seguridad, así que comida saldría verde una vez y azul la siguiente. Sumar los códigos de los caracteres con ord da un número que no cambia nunca.
def importe_con_moneda(importe: Decimal, moneda: str) -> str:
return f"{importe:,.2f} {moneda}".replace(",", " ")
def tabla_gastos(gastos: list[Gasto], moneda: str, pintor: Pintor) -> None:
"""Pinta la lista de gastos en columnas alineadas."""
cabecera = (
f"{'ID':>4} {'FECHA':<10} {'CATEGORÍA':<{ANCHO_CATEGORIA}} "
f"{'CONCEPTO':<{ANCHO_CONCEPTO}} {'IMPORTE':>12}"
)
print(pintor.pinta(cabecera, NEGRITA))
print(pintor.pinta("-" * len(cabecera), APAGADO))
for gasto in gastos:
categoria = f"{gasto.categoria[:ANCHO_CATEGORIA]:<{ANCHO_CATEGORIA}}"
concepto = f"{gasto.concepto[:ANCHO_CONCEPTO]:<{ANCHO_CONCEPTO}}"
importe = f"{importe_con_moneda(gasto.importe, moneda):>12}"
print(
f"{gasto.identificador:>4} "
f"{pintor.pinta(gasto.fecha.isoformat(), APAGADO)} "
f"{pintor.pinta(categoria, pintor.color_de(gasto.categoria))} "
f"{concepto} "
f"{pintor.pinta(importe, NEGRITA)}"
)
def tabla_resumen(gastos: list[Gasto], moneda: str, ancho_barra: int, pintor: Pintor) -> None:
"""Pinta los totales por categoría con su gráfico de barras."""
totales = totales_por_categoria(gastos)
suma = sum((importe for _, importe in totales), Decimal("0"))
mayor = totales[0][1]
cabecera = f"{'CATEGORÍA':<{ANCHO_CATEGORIA}} {'IMPORTE':>12} {'%':>6} GRÁFICO"
print(pintor.pinta(cabecera, NEGRITA))
print(pintor.pinta("-" * (len(cabecera) + ancho_barra - 7), APAGADO))
for categoria, importe in totales:
nombre = f"{categoria[:ANCHO_CATEGORIA]:<{ANCHO_CATEGORIA}}"
peso = f"{porcentaje(importe, suma):>5.1f}%"
dibujo = barra(importe, mayor, ancho_barra)
print(
f"{pintor.pinta(nombre, pintor.color_de(categoria))} "
f"{importe_con_moneda(importe, moneda):>12} {peso} "
f"{pintor.pinta(dibujo, pintor.color_de(categoria))}"
)
print(pintor.pinta("-" * (len(cabecera) + ancho_barra - 7), APAGADO))
total = f"{'TOTAL':<{ANCHO_CATEGORIA}} {importe_con_moneda(suma, moneda):>12}"
print(pintor.pinta(total, NEGRITA))
def aviso(mensaje: str, pintor: Pintor) -> None:
"""Un mensaje de error por stderr, en rojo si hay color."""
print(pintor.pinta(f"gastos: {mensaje}", ROJO), file=sys.stderr)Dos detalles más:
- Los textos se recortan antes de rellenar, con
gasto.concepto[:ANCHO_CONCEPTO]. Un concepto de cuarenta caracteres desplazaría la columna del importe y rompería la alineación de esa fila. - La barra se dibuja contra el mayor de los totales, no contra la suma. Así la categoría más cara siempre llena la barra entera y las demás se comparan con ella, que es lo que el ojo quiere ver. El porcentaje, en cambio, sí se calcula sobre la suma, porque ahí lo que interesa es el reparto.
Parte 6: los subcomandos#
Cinco subcomandos, cada uno con sus opciones. La pieza que los une es set_defaults(funcion=...).
def validador(conversor):
"""Convierte una función de modelo.py en un type= de argparse."""
def convertir(texto: str):
try:
return conversor(texto)
except ValueError as error:
raise argparse.ArgumentTypeError(str(error)) from None
convertir.__name__ = conversor.__name__
return convertirEsta función de seis líneas es el puente entre las dos capas. El modelo lanza ValueError, que es lo correcto para una regla de negocio que no sabe nada de terminales. Pero argparse quiere ArgumentTypeError para poder enseñar su mensaje de uso y salir con el código 2. validador toma una función del modelo y devuelve otra que hace lo mismo y traduce la excepción. Es una función que recibe una función y devuelve una función, exactamente como las que viste en la lección de funciones.
subcomandos = analizador.add_subparsers(dest="subcomando", metavar="SUBCOMANDO")
subcomandos.required = True
anadir = subcomandos.add_parser(
"añadir", aliases=["anadir"], help="apunta un gasto nuevo",
description="Apunta un gasto nuevo.",
)
anadir.add_argument("importe", type=validador(a_importe), help="cuánto, por ejemplo 12,50")
anadir.add_argument("categoria", type=validador(a_categoria), help="comida, transporte, ocio...")
anadir.add_argument("concepto", help="descripción corta, entre comillas si lleva espacios")
anadir.add_argument(
"-f", "--fecha",
type=validador(a_fecha),
default=date.today(),
help="fecha aaaa-mm-dd (por defecto: hoy)",
)
anadir.set_defaults(funcion=accion_anadir)python3 -m gastos --helpusage: gastos [-h] [--datos FICHERO] [--color {auto,siempre,nunca}]
SUBCOMANDO ...
Lleva la cuenta de tus gastos desde la terminal.
positional arguments:
SUBCOMANDO
añadir (anadir) apunta un gasto nuevo
listar enseña los gastos apuntados
resumen totales por categoría con gráfico de barras
borrar elimina un gasto por su identificador
exportar vuelca los gastos a JSON
options:
-h, --help show this help message and exit
--datos FICHERO usa este CSV en vez del de la configuración
--color {auto,siempre,nunca}
cuándo usar color (por defecto: auto)Las decisiones:
set_defaults(funcion=...)guarda la función en el espacio de nombres, ymaintermina conreturn opciones.funcion(opciones, contexto). No hay ningúnmatchgigante sobre el nombre del subcomando: añadir uno nuevo es escribir suadd_parsery su función, y nada más.subcomandos.required = Truehace quegastosa secas sea un error de uso en vez de una ejecución que no hace nada. Desde Python 3, los subcomandos son opcionales por defecto, y casi nunca es lo que quieres.aliases=["anadir"]permite escribirlo sin la eñe. El subcomando se llamaañadirporque el programa está en español, pero teclear una eñe en un teclado ajeno es una lotería, y un alias cuesta seis caracteres.- La fecha por defecto es
date.today(). Ojo con dónde se evalúa: está en el cuerpo deconstruir_analizador, que se llama en cada arranque, así que se calcula bien. Si estuviera como valor por defecto de un parámetro de función, se calcularía una sola vez al importar el módulo, que es la trampa de los argumentos por defecto mutables que ya viste. metavar="SUBCOMANDO"limpia la ayuda: sin él,argparseenseña la lista entera de subcomandos entre llaves en la línea de uso.
Parte 7: las acciones#
Cada subcomando es una función con la misma firma, y las tres capas se ven en cada una: pedir, llamar a la lógica, contar el resultado.
@dataclass
class Contexto:
"""Lo que necesita cualquier acción para hacer su trabajo."""
almacen: Almacen
configuracion: Configuracion
pintor: Pintor
pintor_error: Pintor
def accion_anadir(opciones, contexto: Contexto) -> int:
try:
gasto = contexto.almacen.anadir(
opciones.fecha, opciones.categoria, opciones.concepto, opciones.importe
)
except ValueError as error:
aviso(str(error), contexto.pintor_error)
return 1
contexto.almacen.guardar()
moneda = contexto.configuracion.moneda
print(
f"Apuntado [{gasto.identificador}] "
f"{importe_con_moneda(gasto.importe, moneda)} en {gasto.categoria} el {gasto.fecha}."
)
return 0
def accion_borrar(opciones, contexto: Contexto) -> int:
gasto = contexto.almacen.buscar(opciones.identificador)
if gasto is None:
aviso(f"no hay ningún gasto con el identificador {opciones.identificador}.", contexto.pintor_error)
return 1
if not opciones.si:
if not sys.stdin.isatty():
aviso("no puedo preguntar sin terminal; repite con --si.", contexto.pintor_error)
return 1
moneda = contexto.configuracion.moneda
print(f"Vas a borrar [{gasto.identificador}] {gasto.concepto}, "
f"{importe_con_moneda(gasto.importe, moneda)} del {gasto.fecha}.")
try:
respuesta = input("¿Seguro? [s/N]: ").strip().lower()
except EOFError:
respuesta = ""
print()
if respuesta not in {"s", "si", "sí"}:
print("Cancelado. No se ha borrado nada.")
return 1
contexto.almacen.borrar(opciones.identificador)
contexto.almacen.guardar()
print(f"Borrado el gasto {opciones.identificador}.")
return 0accion_borrar es la interesante, porque borrar es la única operación que destruye datos:
- Se pregunta antes, y se enseña qué se va a borrar. Un identificador tecleado de memoria se equivoca cada dos por tres.
[s/N]con la ene en mayúscula es la convención de toda la terminal para decir cuál es la opción por defecto. Y la comprobación es coherente: cualquier cosa que no sea una confirmación explícita se entiende como un no.--sise salta la pregunta, porque un programa que siempre pregunta no se puede usar dentro de un guion.- Sin terminal no se pregunta, se falla. Si
stdinviene de una tubería,input()leería la siguiente línea de esa tubería como si fuera la respuesta, que es una forma preciosa de borrar datos sin querer. Se comprueba conisatty()y se pide--si. - También se atrapa
EOFError, que es lo que lanzainput()cuando quien usa el programa pulsa Ctrl + D. Se trata como un "no". - Cancelar devuelve 1. "No he hecho lo que me pediste" no es un éxito, y un guion que dependa de que el gasto desaparezca tiene que enterarse.
Las otras tres acciones son más cortas:
def accion_listar(opciones, contexto: Contexto) -> int:
gastos = contexto.almacen.filtrar(opciones.mes, opciones.categoria)
if not gastos:
print("No hay gastos que cumplan esos filtros.")
return 0
tabla_gastos(gastos, contexto.configuracion.moneda, contexto.pintor)
suma = importe_con_moneda(total(gastos), contexto.configuracion.moneda)
etiqueta = "gasto" if len(gastos) == 1 else "gastos"
print(f"\n{len(gastos)} {etiqueta}, {suma} en total.")
return 0
def accion_resumen(opciones, contexto: Contexto) -> int:
gastos = contexto.almacen.filtrar(opciones.mes)
if not gastos:
print("No hay gastos que resumir.")
return 0
titulo = f"Resumen de {opciones.mes}" if opciones.mes else "Resumen de todo"
print(f"{titulo}\n")
tabla_resumen(
gastos,
contexto.configuracion.moneda,
contexto.configuracion.ancho_barra,
contexto.pintor,
)
return 0
def accion_exportar(opciones, contexto: Contexto) -> int:
gastos = contexto.almacen.filtrar(opciones.mes)
datos = [
{
"identificador": gasto.identificador,
"fecha": gasto.fecha.isoformat(),
"categoria": gasto.categoria,
"concepto": gasto.concepto,
"importe": f"{gasto.importe:.2f}",
}
for gasto in gastos
]
texto = json.dumps(datos, indent=2, ensure_ascii=False)
if str(opciones.fichero) == "-":
print(texto)
return 0
try:
opciones.fichero.write_text(texto + "\n", encoding="utf-8")
except OSError as error:
aviso(f"no he podido escribir en {opciones.fichero}: {error}", contexto.pintor_error)
return 1
print(f"Exportados {len(datos)} gastos a {opciones.fichero}.")
return 0De accion_exportar, tres cosas:
- El importe se exporta como cadena,
"12.50". JSON no tiene tipo decimal: sus números son de coma flotante, así que escribir elDecimalcomo número volvería a meter la imprecisión que tanto trabajo ha costado evitar. Quien lea el JSON reconstruye elDecimaldesde el texto. ensure_ascii=Falsepara que las eñes y los acentos se guarden tal cual y el fichero se pueda leer.- El guion como nombre de fichero significa "la salida estándar", que es una convención universal en la terminal. Eso convierte el subcomando en una pieza encadenable:
gastos exportar - --mes 2026-09 | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d), 'gastos')"8 gastosParte 8: el arranque#
def main(argumentos: list[str] | None = None) -> int:
opciones = construir_analizador().parse_args(argumentos)
pintor = Pintor(quiere_color(opciones.color, sys.stdout))
pintor_error = Pintor(quiere_color(opciones.color, sys.stderr))
try:
configuracion = cargar_configuracion()
except ValueError as error:
aviso(str(error), pintor_error)
return 1
if opciones.datos is not None:
configuracion.datos = opciones.datos
almacen = Almacen(configuracion.datos)
try:
almacen.cargar()
except (ErrorDeAlmacen, OSError) as error:
aviso(str(error), pintor_error)
return 1
contexto = Contexto(almacen, configuracion, pintor, pintor_error)
return opciones.funcion(opciones, contexto)
def ejecutar() -> int:
"""Punto de entrada del comando instalado. Aquí muere el Ctrl+C."""
try:
return main()
except KeyboardInterrupt:
print("\nCancelado.", file=sys.stderr)
return 130main hace siempre lo mismo, pase lo que pase: prepara los dos pintores, resuelve la configuración, carga el almacén y delega en la acción que toque. Todo lo específico de cada subcomando está en su función.
- Dos pintores, uno para cada flujo. No es exageración:
gastos listar > fichero.txtmandastdouta un fichero pero dejastderren la terminal. Con un solo pintor, o el fichero se llena de códigos de color o los errores salen en gris. ejecutarenvuelve amainpara el Ctrl + C. Sin eso, interrumpir el programa mientras espera la confirmación de un borrado suelta un traceback de seis líneas. El código 130 es la convención de la terminal para "interrumpido por el usuario": 128 más el número de la señal, que para la interrupción es 2.maindevuelve un número y no llama asys.exit, por lo mismo que en el proyecto anterior: así se puede llamar desde una prueba con una lista de argumentos y mirar el resultado.
El fichero cli.py completo#
Las piezas de los dos apartados anteriores, en su sitio y en orden:
"""La interfaz de terminal: argumentos, subcomandos y códigos de salida."""
import argparse
import json
import sys
from dataclasses import dataclass
from datetime import date
from pathlib import Path
from .almacen import Almacen, ErrorDeAlmacen, total
from .configuracion import Configuracion, cargar_configuracion
from .modelo import a_categoria, a_fecha, a_importe, a_mes
from .presentacion import (
Pintor,
aviso,
importe_con_moneda,
quiere_color,
tabla_gastos,
tabla_resumen,
)
@dataclass
class Contexto:
"""Lo que necesita cualquier acción para hacer su trabajo."""
almacen: Almacen
configuracion: Configuracion
pintor: Pintor
pintor_error: Pintor
def validador(conversor):
"""Convierte una función de modelo.py en un type= de argparse."""
def convertir(texto: str):
try:
return conversor(texto)
except ValueError as error:
raise argparse.ArgumentTypeError(str(error)) from None
convertir.__name__ = conversor.__name__
return convertir
def construir_analizador() -> argparse.ArgumentParser:
analizador = argparse.ArgumentParser(
prog="gastos",
description="Lleva la cuenta de tus gastos desde la terminal.",
)
analizador.add_argument(
"--datos",
type=Path,
metavar="FICHERO",
help="usa este CSV en vez del de la configuración",
)
analizador.add_argument(
"--color",
choices=["auto", "siempre", "nunca"],
default="auto",
help="cuándo usar color (por defecto: auto)",
)
subcomandos = analizador.add_subparsers(dest="subcomando", metavar="SUBCOMANDO")
subcomandos.required = True
anadir = subcomandos.add_parser(
"añadir", aliases=["anadir"], help="apunta un gasto nuevo",
description="Apunta un gasto nuevo.",
)
anadir.add_argument("importe", type=validador(a_importe), help="cuánto, por ejemplo 12,50")
anadir.add_argument("categoria", type=validador(a_categoria), help="comida, transporte, ocio...")
anadir.add_argument("concepto", help="descripción corta, entre comillas si lleva espacios")
anadir.add_argument(
"-f", "--fecha",
type=validador(a_fecha),
default=date.today(),
help="fecha aaaa-mm-dd (por defecto: hoy)",
)
anadir.set_defaults(funcion=accion_anadir)
listar = subcomandos.add_parser(
"listar", help="enseña los gastos apuntados",
description="Enseña los gastos, con filtros opcionales.",
)
listar.add_argument("-m", "--mes", type=validador(a_mes), help="solo los de este mes, aaaa-mm")
listar.add_argument("-c", "--categoria", type=validador(a_categoria), help="solo los de esta categoría")
listar.set_defaults(funcion=accion_listar)
resumen = subcomandos.add_parser(
"resumen", help="totales por categoría con gráfico de barras",
description="Totales por categoría con gráfico de barras.",
)
resumen.add_argument("-m", "--mes", type=validador(a_mes), help="solo los de este mes, aaaa-mm")
resumen.set_defaults(funcion=accion_resumen)
borrar = subcomandos.add_parser(
"borrar", help="elimina un gasto por su identificador",
description="Elimina un gasto por su identificador.",
)
borrar.add_argument("identificador", type=int, help="el número que sale en la columna ID")
borrar.add_argument("-s", "--si", action="store_true", help="no preguntar antes de borrar")
borrar.set_defaults(funcion=accion_borrar)
exportar = subcomandos.add_parser(
"exportar", help="vuelca los gastos a JSON",
description="Vuelca los gastos a un fichero JSON, o a la salida estándar con -.",
)
exportar.add_argument("fichero", type=Path, help="fichero de destino, o - para la salida estándar")
exportar.add_argument("-m", "--mes", type=validador(a_mes), help="solo los de este mes, aaaa-mm")
exportar.set_defaults(funcion=accion_exportar)
return analizador
def accion_anadir(opciones, contexto: Contexto) -> int:
try:
gasto = contexto.almacen.anadir(
opciones.fecha, opciones.categoria, opciones.concepto, opciones.importe
)
except ValueError as error:
aviso(str(error), contexto.pintor_error)
return 1
contexto.almacen.guardar()
moneda = contexto.configuracion.moneda
print(
f"Apuntado [{gasto.identificador}] "
f"{importe_con_moneda(gasto.importe, moneda)} en {gasto.categoria} el {gasto.fecha}."
)
return 0
def accion_listar(opciones, contexto: Contexto) -> int:
gastos = contexto.almacen.filtrar(opciones.mes, opciones.categoria)
if not gastos:
print("No hay gastos que cumplan esos filtros.")
return 0
tabla_gastos(gastos, contexto.configuracion.moneda, contexto.pintor)
suma = importe_con_moneda(total(gastos), contexto.configuracion.moneda)
etiqueta = "gasto" if len(gastos) == 1 else "gastos"
print(f"\n{len(gastos)} {etiqueta}, {suma} en total.")
return 0
def accion_resumen(opciones, contexto: Contexto) -> int:
gastos = contexto.almacen.filtrar(opciones.mes)
if not gastos:
print("No hay gastos que resumir.")
return 0
titulo = f"Resumen de {opciones.mes}" if opciones.mes else "Resumen de todo"
print(f"{titulo}\n")
tabla_resumen(
gastos,
contexto.configuracion.moneda,
contexto.configuracion.ancho_barra,
contexto.pintor,
)
return 0
def accion_borrar(opciones, contexto: Contexto) -> int:
gasto = contexto.almacen.buscar(opciones.identificador)
if gasto is None:
aviso(f"no hay ningún gasto con el identificador {opciones.identificador}.", contexto.pintor_error)
return 1
if not opciones.si:
if not sys.stdin.isatty():
aviso("no puedo preguntar sin terminal; repite con --si.", contexto.pintor_error)
return 1
moneda = contexto.configuracion.moneda
print(f"Vas a borrar [{gasto.identificador}] {gasto.concepto}, "
f"{importe_con_moneda(gasto.importe, moneda)} del {gasto.fecha}.")
try:
respuesta = input("¿Seguro? [s/N]: ").strip().lower()
except EOFError:
respuesta = ""
print()
if respuesta not in {"s", "si", "sí"}:
print("Cancelado. No se ha borrado nada.")
return 1
contexto.almacen.borrar(opciones.identificador)
contexto.almacen.guardar()
print(f"Borrado el gasto {opciones.identificador}.")
return 0
def accion_exportar(opciones, contexto: Contexto) -> int:
gastos = contexto.almacen.filtrar(opciones.mes)
datos = [
{
"identificador": gasto.identificador,
"fecha": gasto.fecha.isoformat(),
"categoria": gasto.categoria,
"concepto": gasto.concepto,
"importe": f"{gasto.importe:.2f}",
}
for gasto in gastos
]
texto = json.dumps(datos, indent=2, ensure_ascii=False)
if str(opciones.fichero) == "-":
print(texto)
return 0
try:
opciones.fichero.write_text(texto + "\n", encoding="utf-8")
except OSError as error:
aviso(f"no he podido escribir en {opciones.fichero}: {error}", contexto.pintor_error)
return 1
print(f"Exportados {len(datos)} gastos a {opciones.fichero}.")
return 0
def main(argumentos: list[str] | None = None) -> int:
opciones = construir_analizador().parse_args(argumentos)
pintor = Pintor(quiere_color(opciones.color, sys.stdout))
pintor_error = Pintor(quiere_color(opciones.color, sys.stderr))
try:
configuracion = cargar_configuracion()
except ValueError as error:
aviso(str(error), pintor_error)
return 1
if opciones.datos is not None:
configuracion.datos = opciones.datos
almacen = Almacen(configuracion.datos)
try:
almacen.cargar()
except (ErrorDeAlmacen, OSError) as error:
aviso(str(error), pintor_error)
return 1
contexto = Contexto(almacen, configuracion, pintor, pintor_error)
return opciones.funcion(opciones, contexto)
def ejecutar() -> int:
"""Punto de entrada del comando instalado. Aquí muere el Ctrl+C."""
try:
return main()
except KeyboardInterrupt:
print("\nCancelado.", file=sys.stderr)
return 130
if __name__ == "__main__":
sys.exit(ejecutar())Son unas 250 líneas, y la mitad son los add_argument de los cinco subcomandos.
"""Permite ejecutar el paquete con python3 -m gastos."""
import sys
from .cli import ejecutar
sys.exit(ejecutar())El empaquetado#
Con un pyproject.toml de doce líneas, gastos deja de ser un python3 -m y pasa a ser un comando:
[project]
name = "gastos"
version = "1.0.0"
description = "Control de gastos personales desde la terminal"
requires-python = ">=3.11"
[project.scripts]
gastos = "gastos.cli:ejecutar"
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"python3 -m venv .venv
source .venv/bin/activate
pip install -e .
gastos resumen --mes 2026-09La línea gastos = "gastos.cli:ejecutar" dice: crea un comando llamado gastos que llame a la función ejecutar del módulo gastos.cli. Apunta a ejecutar y no a main para que el comando instalado también sepa morirse con elegancia con Ctrl + C. Y el instalador envuelve esa función en un sys.exit(...), así que el número que devuelve se convierte en el código de salida del proceso.
Una sesión de uso#
Se empieza apuntando gastos. La fecha se puede omitir, y entonces es la de hoy; aquí se fija a propósito para que el ejemplo no cambie mañana.
gastos añadir 12,50 comida "Menú del día" --fecha 2026-09-01
gastos añadir 45.90 transporte "Abono mensual" -f 2026-09-01
gastos añadir 8,20 comida "Café y tostada" -f 2026-09-03
gastos añadir 62 ocio "Entradas del teatro" -f 2026-09-05
gastos añadir 129.99 hogar "Lámpara del salón" -f 2026-09-08
gastos añadir 3,40 transporte "Billete de metro" -f 2026-09-09
gastos añadir 27,15 comida "Compra semanal" -f 2026-09-12
gastos añadir 19,99 ocio "Suscripción de música" -f 2026-09-15
gastos añadir 54,30 comida "Cena con Marta" -f 2026-08-28Apuntado [1] 12.50 € en comida el 2026-09-01.
Apuntado [2] 45.90 € en transporte el 2026-09-01.
Apuntado [3] 8.20 € en comida el 2026-09-03.
Apuntado [4] 62.00 € en ocio el 2026-09-05.
Apuntado [5] 129.99 € en hogar el 2026-09-08.
Apuntado [6] 3.40 € en transporte el 2026-09-09.
Apuntado [7] 27.15 € en comida el 2026-09-12.
Apuntado [8] 19.99 € en ocio el 2026-09-15.
Apuntado [9] 54.30 € en comida el 2026-08-28.Da igual el orden en que se apunten: el fichero sale ordenado por fecha, y se puede abrir con cualquier hoja de cálculo.
identificador,fecha,categoria,concepto,importe
9,2026-08-28,comida,Cena con Marta,54.30
1,2026-09-01,comida,Menú del día,12.50
2,2026-09-01,transporte,Abono mensual,45.90
3,2026-09-03,comida,Café y tostada,8.20
4,2026-09-05,ocio,Entradas del teatro,62.00
5,2026-09-08,hogar,Lámpara del salón,129.99
6,2026-09-09,transporte,Billete de metro,3.40
7,2026-09-12,comida,Compra semanal,27.15
8,2026-09-15,ocio,Suscripción de música,19.99La lista completa:
gastos listar ID FECHA CATEGORÍA CONCEPTO IMPORTE
--------------------------------------------------------------------------
9 2026-08-28 comida Cena con Marta 54.30 €
1 2026-09-01 comida Menú del día 12.50 €
2 2026-09-01 transporte Abono mensual 45.90 €
3 2026-09-03 comida Café y tostada 8.20 €
4 2026-09-05 ocio Entradas del teatro 62.00 €
5 2026-09-08 hogar Lámpara del salón 129.99 €
6 2026-09-09 transporte Billete de metro 3.40 €
7 2026-09-12 comida Compra semanal 27.15 €
8 2026-09-15 ocio Suscripción de música 19.99 €
9 gastos, 363.43 € en total.Con los dos filtros a la vez:
gastos listar --mes 2026-09 --categoria comida ID FECHA CATEGORÍA CONCEPTO IMPORTE
--------------------------------------------------------------------------
1 2026-09-01 comida Menú del día 12.50 €
3 2026-09-03 comida Café y tostada 8.20 €
7 2026-09-12 comida Compra semanal 27.15 €
3 gastos, 47.85 € en total.El resumen del mes, que es para lo que se hizo todo esto:
gastos resumen --mes 2026-09Resumen de 2026-09
CATEGORÍA IMPORTE % GRÁFICO
--------------------------------------------------------------------
hogar 129.99 € 42.1% ████████████████████████████████
ocio 81.99 € 26.5% ████████████████████▏
transporte 49.30 € 15.9% ████████████▏
comida 47.85 € 15.5% ███████████▊
--------------------------------------------------------------------
TOTAL 309.13 €En la terminal, cada nombre de categoría y su barra salen del mismo color, la cabecera en negrita y las líneas de guiones atenuadas. Esta captura está tomada redirigiendo la salida a un fichero, así que isatty() devolvió False y el programa apagó el color él solo, que es justo lo que se buscaba.
Un borrado, con su confirmación:
gastos borrar 6Vas a borrar [6] Billete de metro, 3.40 € del 2026-09-09.
¿Seguro? [s/N]: s
Borrado el gasto 6.Y el mismo borrado cuando uno se lo piensa mejor:
gastos borrar 6
echo $?Vas a borrar [6] Billete de metro, 3.40 € del 2026-09-09.
¿Seguro? [s/N]: n
Cancelado. No se ha borrado nada.
1La exportación a JSON:
gastos exportar gastos-septiembre.json --mes 2026-09Exportados 8 gastos a gastos-septiembre.json.[
{
"identificador": 1,
"fecha": "2026-09-01",
"categoria": "comida",
"concepto": "Menú del día",
"importe": "12.50"
},
{
"identificador": 2,
"fecha": "2026-09-01",
"categoria": "transporte",
"concepto": "Abono mensual",
"importe": "45.90"
}
]Con --datos se puede llevar una contabilidad aparte sin tocar la principal, por ejemplo la de un viaje:
gastos --datos viaje.csv añadir 340 transporte "Vuelo a Bilbao" -f 2026-09-10
gastos --datos viaje.csv listarApuntado [1] 340.00 € en transporte el 2026-09-10.
ID FECHA CATEGORÍA CONCEPTO IMPORTE
--------------------------------------------------------------------------
1 2026-09-10 transporte Vuelo a Bilbao 340.00 €
1 gasto, 340.00 € en total.Y los errores, todos con su mensaje por stderr y su código de salida:
gastos añadir doce comida "Prueba"
echo $?usage: gastos añadir [-h] [-f FECHA] importe categoria concepto
gastos añadir: error: argument importe: 'doce' no es un importe
2gastos añadir 5 comida "Prueba" -f 31-12-2026
echo $?usage: gastos añadir [-h] [-f FECHA] importe categoria concepto
gastos añadir: error: argument -f/--fecha: '31-12-2026' no es una fecha aaaa-mm-dd
2gastos borrar 99 --si
echo $?gastos: no hay ningún gasto con el identificador 99.
1Fíjate en que los mensajes de los dos primeros salen tal como los escribiste en modelo.py, aunque esas funciones no sepan que existe argparse. Eso lo hace el puente de seis líneas de validador.
Lo que se ha usado#
| Del curso | Dónde |
|---|---|
Decimal y quantize | Todo el manejo del dinero |
datetime.date, fromisoformat, strftime | modelo.py y los filtros por mes |
| Dataclasses y propiedades | Gasto, Configuracion, Contexto |
Excepciones propias y raise ... from | ErrorDeAlmacen y las conversiones |
CSV con DictReader y DictWriter | almacen.py |
| JSON | La configuración y la exportación |
pathlib, replace, expanduser | El guardado seguro y las rutas |
Comprensiones y sorted con clave compuesta | filtrar y totales_por_categoria |
| Funciones que devuelven funciones | validador |
argparse con subcomandos y set_defaults | cli.py |
Colores ANSI, isatty, tablas y barras | presentacion.py e informes.py |
os.environ, códigos de salida, stderr | La configuración y main |
pyproject.toml con [project.scripts] | El empaquetado |