Curso de Python / Projeto final: criar um gerenciador de despesas com SQLite

O projeto final recebe a data, a categoria e a quantia de uma despesa, grava os dados em SQLite e consulta registros ou totais por categoria. Inicialização explícita, validação, transações e subcomandos conectam as técnicas centrais do curso em uma ferramenta de terminal.

Definir o escopo e as regras dos dados

As quantias são inteiros não negativos em unidades inteiras de won sul-coreano, mantendo o contrato original do exercício. Não há conversão de moedas nem suporte automático a centavos de reais. A data deve existir no calendário e ser escrita como YYYY-MM-DD; a categoria não pode ser vazia. O limite de uma quantia é 1.000.000.000.000, inclusive.

Cada chamada de add acrescenta uma nova despesa, mesmo se os valores forem iguais aos de uma linha anterior. Isso é diferente do coletor por ID da aula anterior: repetir aqui uma inclusão bem-sucedida realmente aumenta a quantidade de registros.

Use Python 3.12 ou posterior e uma pasta nova. A biblioteca padrão é suficiente. O arquivo padrão ledger.db fica no diretório atual do terminal; --db escolhe outro caminho. Não aponte a prática para um banco de trabalho ou de outro exercício. A ferramenta não implementa pagamentos, integração bancária ou autenticação de vários usuários.

As aulas de SQLite com parâmetros e transações e interfaces com argparse apresentam os componentes usados. A documentação de subcomandos de argparse explica como as operações são separadas.

Salvar e compreender o programa completo

Salve o código abaixo como ledger.py, em UTF-8, na pasta de prática.

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

amount_value converte o texto em inteiro e verifica o intervalo. date_value interpreta a data e compara sua forma normalizada com a entrada: isso recusa dias inexistentes e representações alternativas, como uma data sem hífens. Consulte date.fromisoformat. main escolhe o comando e gerencia a conexão.

Os valores são passados ao SQL por parâmetros. A categoria é aparada com strip() antes da inclusão. As escritas ficam dentro de with con, com commit na saída normal ou rollback em caso de exceção; finally fecha a conexão. A documentação de controle de transações em sqlite3 detalha essa separação.

Inicializar, inserir despesas e consultar resultados

No terminal da pasta, selecione o ambiente virtual do projeto e execute cada comando na ordem indicada:

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

A inicialização imprime initialized; cada inclusão bem-sucedida imprime added. A listagem mostra ID, data, categoria e quantia, ordenados por data e depois ID. Numa base nova, os três IDs iniciais são 1, 2 e 3. O resumo deve ser:

food 11500
transport 2500

No macOS ou Ubuntu, fora de um ambiente virtual, use python3 se esse for o nome do interpretador. As categorias food e transport significam alimentação e transporte; os rótulos foram mantidos no exemplo para facilitar a comparação com suas saídas.

init não apaga a tabela existente nem migra seu esquema. Para repetir a sequência desde uma base vazia, escolha outra pasta ou outro arquivo com python ledger.py --db other-ledger.db init. Coloque a opção global --db antes do subcomando e use o mesmo caminho nos comandos seguintes. Um caminho com espaços precisa ficar entre aspas. A pasta pai do arquivo já deve existir; o programa não cria essa estrutura automaticamente.

Verificar rejeições, persistência e repetição

  1. Tente incluir a data 2026-02-30. Ela deve ser rejeitada antes da escrita.
  2. Teste uma quantia negativa, uma acima do limite e uma categoria formada somente por espaços, usando aspas para transmitir essa categoria como um argumento. Depois rode list e confira que as rejeições não criaram novas linhas.
  3. Encerre a execução e chame list de novo. Cada comando é um processo separado, e os registros devem continuar presentes no arquivo do banco.
  4. Execute init novamente e confirme que as três despesas permanecem. Em contraste, repetir um comando add válido cria uma quarta despesa; faça essa comparação apenas na base descartável.
  5. Use uma nova base para reproduzir os três registros e o resumo original, sem apagar um arquivo desconhecido.

Erros de argumentos normalmente terminam com código 2 e uma mensagem de uso. Confira $LASTEXITCODE no PowerShell ou echo $? em um shell de macOS ou Ubuntu imediatamente após a chamada. Uma base inexistente é recusada por list, summary e add. Um arquivo que existe, mas não contém o esquema esperado, ainda pode causar uma exceção de banco; o teste de existência não comprova sua compatibilidade.

O resumo acumula inteiros em Python em vez de usar SUM do SQLite. Isso evita o limite do acumulador inteiro SQL em bases suficientemente grandes, sem trocar a soma por ponto flutuante. Veja as regras de sum e total do SQLite. As linhas são percorridas de forma incremental, mas o dicionário dos totais cresce com a quantidade de categorias.

Executar um teste de integração e separar responsabilidades

Salve este segundo programa completo como check_ledger.py, na mesma pasta de ledger.py. Execute python check_ledger.py sem -O, pois essa opção pode remover as instruções assert.

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

Resultado esperado:

integration checks passed

O teste usa uma base temporária independente, inicializa-a, acrescenta uma despesa e verifica rejeições de data e quantias, além do resumo e da contagem da listagem. A pasta temporária é removida na saída do contexto. Esse teste não modifica a base usada nos comandos manuais. A aula de testes unitários e de integração explica a diferença entre as verificações.

Em uma ampliação, separe interpretação de argumentos, validação, acesso ao banco e agregação em módulos, mantendo testes dos comportamentos existentes. Preserve o ponto de entrada main para que importar o módulo não crie um banco nem interprete argumentos de outro programa.

As restrições do banco complementam a validação da interface, mas mudanças de esquema, backup, restauração e acesso concorrente exigem projeto próprio. Antes de guardar despesas pessoais reais, defina permissões do arquivo e locais de cópia ou compartilhamento. Avalie a ferramenta pela rejeição de entradas erradas e pelo significado da repetição dos comandos, além da soma correta.