Curso de Python / Proyecto final de un gestor de gastos con SQLite
El proyecto final recibe la fecha, categoría e importe de un gasto, lo guarda en SQLite y permite consultar registros y totales. La inicialización explícita, la validación y las transacciones conectan las técnicas principales del curso en una herramienta de línea de comandos completa.
Definir el alcance y preparar la base de prácticas
Los importes son enteros no negativos expresados en unidades completas de KRW. Las fechas utilizan YYYY-MM-DD y las categorías no pueden quedar vacías. Cada llamada a add crea un gasto nuevo; repetirla añade otra fila. No es el mismo contrato de actualización por identificador del recopilador de API.
Necesitas Python 3.12 o posterior y la biblioteca estándar. Guarda ledger.py en una carpeta nueva. La base predeterminada ledger.db se interpreta respecto del directorio de trabajo; --db permite elegir otra ruta. No utilices una base de negocio ni de ejercicios ajenos. Las integraciones bancarias, pagos y autenticación multiusuario quedan fuera del programa.
Guardar el programa completo
amount_value admite enteros entre cero y 1.000.000.000.000. date_value comprueba un día real y exige la forma exacta indicada. main interpreta argumentos, elige la operación y controla la conexión.
import argparse
import sqlite3
from datetime import date
from pathlib import Path
MAX_AMOUNT = 1_000_000_000_000
def amount_value(text):
try:
value = int(text)
except ValueError as error:
raise argparse.ArgumentTypeError("expected an integer") from error
if not 0 <= value <= MAX_AMOUNT:
raise argparse.ArgumentTypeError("amount must be between 0 and 1000000000000")
return value
def date_value(text):
try:
parsed = date.fromisoformat(text)
except ValueError as error:
raise argparse.ArgumentTypeError("invalid date") from error
if parsed.isoformat() != text:
raise argparse.ArgumentTypeError("use YYYY-MM-DD")
return text
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--db", type=Path, default=Path("ledger.db"))
commands = parser.add_subparsers(dest="command", required=True)
commands.add_parser("init")
add = commands.add_parser("add")
add.add_argument("day", type=date_value)
add.add_argument("category")
add.add_argument("amount", type=amount_value)
commands.add_parser("list")
commands.add_parser("summary")
args = parser.parse_args()
if args.command != "init" and not args.db.is_file():
parser.error("database not initialized")
if args.command == "add" and not args.category.strip():
parser.error("category is required")
con = sqlite3.connect(args.db, autocommit=False)
try:
if args.command == "init":
with con:
con.execute("CREATE TABLE IF NOT EXISTS expenses (id INTEGER PRIMARY KEY, day TEXT NOT NULL, category TEXT NOT NULL CHECK(length(trim(category))>0), amount INTEGER NOT NULL CHECK(amount BETWEEN 0 AND 1000000000000))")
print("initialized")
elif args.command == "add":
with con:
con.execute("INSERT INTO expenses(day,category,amount) VALUES(?,?,?)", (args.day, args.category.strip(), args.amount))
print("added")
elif args.command == "list":
for row in con.execute("SELECT id,day,category,amount FROM expenses ORDER BY day,id"):
print(*row)
else:
totals = {}
for category, amount in con.execute("SELECT category,amount FROM expenses"):
totals[category] = totals.get(category, 0) + amount
for category, total in sorted(totals.items()):
print(category, total)
finally:
con.close()
if __name__ == "__main__":
main()
Las escrituras utilizan parámetros y un bloque transaccional; el finally cierra la conexión. El resumen recorre las filas y suma enteros de Python para evitar el desbordamiento de una suma entera SQL. La memoria del diccionario de totales crece con el número de categorías.
Inicializar, añadir y consultar gastos
Desde la carpeta del programa, ejecuta las siguientes órdenes en este orden:
python ledger.py init
python ledger.py add 2026-10-17 food 3500
python ledger.py add 2026-10-17 food 8000
python ledger.py add 2026-10-17 transport 2500
python ledger.py list
python ledger.py summary
init muestra initialized y cada alta correcta muestra added. El listado presenta identificador, fecha, categoría e importe, ordenados por fecha e identificador. El resumen final debe ser:
food 11500
transport 2500
La inicialización no borra una tabla existente ni actualiza sus restricciones. Para repetir desde cero, utiliza otra carpeta o una ruta nueva. La opción global va antes del subcomando: python ledger.py --db other-ledger.db init. No elimines una base desconocida para reiniciar la práctica.
Probar rechazos y persistencia
- Prueba
2026-02-30y confirma que no se guarda una fila. - Prueba un importe negativo y una categoría en blanco; comprueba que el listado no aumenta.
- Cierra y vuelve a ejecutar el programa con
listpara comprobar la persistencia. - En otra base nueva, reproduce los tres gastos y compara los totales.
Para automatizar parte de estas comprobaciones, guarda check_ledger.py junto a ledger.py y ejecuta python check_ledger.py. El programa de pruebas utiliza su propia base temporal.
import subprocess
import sys
import tempfile
from pathlib import Path
program = Path("ledger.py").resolve()
with tempfile.TemporaryDirectory() as folder:
db = Path(folder) / "test.db"
def run(*args):
return subprocess.run([sys.executable, str(program), "--db", str(db), *args], capture_output=True, text=True)
assert run("init").returncode == 0
assert run("add", "2026-10-17", "food", "8000").returncode == 0
assert run("add", "2026-02-30", "food", "8000").returncode != 0
assert run("add", "2026-10-17", "food", "-1").returncode != 0
assert run("add", "2026-10-17", "food", "1000000000001").returncode != 0
result = run("summary")
assert result.returncode == 0 and result.stdout.strip() == "food 8000"
assert len(run("list").stdout.splitlines()) == 1
print("integration checks passed")
integration checks passed
La prueba comprueba almacenamiento válido, fecha imposible, importe negativo, límite superior, resumen y número de filas. No demuestra todas las condiciones posibles ni sustituye pruebas de concurrencia. La lección de pruebas automatizadas explica cómo ampliar los casos sin utilizar datos personales.
Mantener responsabilidades al ampliar la aplicación
Un solo archivo facilita inspeccionar este ejemplo introductorio. Si crece, separa argumentos, validación, acceso a datos y agregación en módulos mientras las pruebas conservan su comportamiento. Mantén la protección if __name__ == "__main__" para que importar el módulo no cree bases ni pida datos.
La introducción a SQLite explica el límite de la transacción. Para datos reales añade migraciones respaldadas, recuperación, permisos de archivo y una política de usuarios concurrentes. Consulta la referencia de sqlite3 y las condiciones de sum y total antes de sustituir las reglas de almacenamiento o cálculo.




