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
- Tente incluir a data
2026-02-30. Ela deve ser rejeitada antes da escrita. - 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
liste confira que as rejeições não criaram novas linhas. - Encerre a execução e chame
listde novo. Cada comando é um processo separado, e os registros devem continuar presentes no arquivo do banco. - Execute
initnovamente e confirme que as três despesas permanecem. Em contraste, repetir um comandoaddválido cria uma quarta despesa; faça essa comparação apenas na base descartável. - 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.




