Capítulo 63, Projetos
Projeto Básico: gerenciador de despesas
Os exercícios pequenos fixam a sintaxe. Um projeto de verdade ensina outra coisa: decidir o que vai em cada função, o que persiste e o que acontece quando a entrada é ruim. Faça este depois do capítulo 25.
Os arquivos deste capítulo estão em projetos/despesas/.
O que você vai construir
Um programa de terminal para registrar despesas, listar, ver o total por categoria e remover. Os dados ficam em um arquivo e sobrevivem entre as execuções. Tudo usa o que a parte Básico ensinou (funções, listas, dicionários, exceções, arquivos, módulos), mais o módulo json, que o capítulo 39 detalha.
| Requisito | Conceito que treina |
|---|---|
| Registrar descrição, valor e categoria | Funções, validação, exceções |
| Guardar o valor em centavos | Tipos (nunca float para dinheiro) |
Persistir em despesas.json | Arquivos, encoding, json |
| Resumo por categoria | Dicionários, ordenação |
| Recusar entrada inválida sem cair | try/except com mensagens claras |
| Um arquivo de autoteste | assert, funções como objetos |
Como eu pensaria antes de digitar
Separe o que calcula do o que conversa com a pessoa. As funções ler_valor, adicionar, total_por_categoria e remover não usam input nem print: recebem dados e devolvem dados (ou levantam ValueError). Só mostrar_lista, mostrar_resumo e main interagem. Essa divisão é o que permite testá-las sem digitar nada, e é a mesma ideia de separar a regra do mundo externo que reaparece até no capítulo 51.
Três decisões que valem a pena notar no código:
- Centavos inteiros.
12,50vira1250. Somar0,10 + 0,20emfloatnão dá0,30, e em um sistema financeiro isso é bug. - Validar na entrada.
adicionarrecusa descrição vazia, categoria desconhecida e valor não positivo, emainmostra o motivo em vez de quebrar. - Gravar a cada mudança. Se o programa fechar de repente, nada se perde.
O código
"""Gerenciador de despesas em linha de comando (projeto da parte Básico).
Usa o que foi visto até o capítulo 25, mais o módulo json da biblioteca padrão.
Os valores ficam em centavos (inteiros), para nunca somar centavos com float.
"""
import json
from datetime import date
from pathlib import Path
ARQUIVO = Path("despesas.json")
CATEGORIAS = ["alimentação", "transporte", "moradia", "lazer", "outros"]
def carregar(caminho):
"""Lê as despesas do arquivo. Se ele ainda não existir, devolve uma lista vazia."""
try:
with open(caminho, encoding="utf-8") as arquivo:
return json.load(arquivo)
except FileNotFoundError:
return []
def salvar(caminho, despesas):
with open(caminho, "w", encoding="utf-8") as arquivo:
json.dump(despesas, arquivo, ensure_ascii=False, indent=2)
def ler_valor(texto):
"""Converte '12,50' em centavos (1250). Levanta ValueError se o valor for inválido."""
try:
valor = float(texto.replace(",", "."))
except ValueError as erro:
raise ValueError(f"valor inválido: {texto!r}") from erro
if valor <= 0:
raise ValueError("o valor deve ser positivo")
return round(valor * 100)
def formatar_reais(centavos):
reais, resto = divmod(centavos, 100)
return f"R$ {reais:,}".replace(",", ".") + f",{resto:02d}"
def adicionar(despesas, descricao, valor_texto, categoria, data=None):
if not descricao.strip():
raise ValueError("a descrição não pode ser vazia")
if categoria not in CATEGORIAS:
raise ValueError(f"categoria inválida: {categoria!r}")
despesa = {
"descricao": descricao.strip(),
"centavos": ler_valor(valor_texto),
"categoria": categoria,
"data": data or date.today().isoformat(),
}
despesas.append(despesa)
return despesa
def remover(despesas, posicao):
if not 1 <= posicao <= len(despesas):
raise ValueError(f"não existe a despesa número {posicao}")
return despesas.pop(posicao - 1)
def total_por_categoria(despesas):
totais = {}
for despesa in despesas:
categoria = despesa["categoria"]
totais[categoria] = totais.get(categoria, 0) + despesa["centavos"]
return totais
def mostrar_lista(despesas):
if not despesas:
print("Nenhuma despesa registrada.")
return
for posicao, d in enumerate(despesas, start=1):
print(f"{posicao}. {d['data']} {d['descricao']:<20} {formatar_reais(d['centavos']):>12} [{d['categoria']}]")
def mostrar_resumo(despesas):
totais = total_por_categoria(despesas)
for categoria in sorted(totais, key=totais.get, reverse=True):
print(f"{categoria:<12} {formatar_reais(totais[categoria]):>12}")
print(f"{'total':<12} {formatar_reais(sum(totais.values())):>12}")
def main():
despesas = carregar(ARQUIVO)
while True:
print("\n1) Adicionar 2) Listar 3) Resumo 4) Remover 0) Sair")
opcao = input("Escolha: ").strip()
if opcao == "1":
descricao = input("Descrição: ")
valor = input("Valor (ex.: 12,50): ")
print("Categorias:", ", ".join(CATEGORIAS))
categoria = input("Categoria: ").strip().lower()
try:
adicionar(despesas, descricao, valor, categoria)
except ValueError as erro:
print(f"Não foi possível adicionar: {erro}")
else:
salvar(ARQUIVO, despesas)
print("Despesa registrada.")
elif opcao == "2":
mostrar_lista(despesas)
elif opcao == "3":
mostrar_resumo(despesas)
elif opcao == "4":
try:
remover(despesas, int(input("Número da despesa: ")))
except ValueError as erro:
print(f"Não foi possível remover: {erro}")
else:
salvar(ARQUIVO, despesas)
print("Despesa removida.")
elif opcao == "0":
break
else:
print("Opção inválida.")
if __name__ == "__main__":
main()
O autoteste
Sem pytest ainda (ele só aparece no capítulo 40), o teste usa assert e uma pequena convenção: todo nome que começa com testar_ é uma função de teste, e o laço final as executa. Repare que ele usa uma pasta temporária, e por isso nunca mexe no seu despesas.json de verdade:
"""Autoteste do gerenciador de despesas. Rode com: python testar_despesas.py"""
import tempfile
from pathlib import Path
import despesas
def testar_ler_valor():
assert despesas.ler_valor("12,50") == 1250
assert despesas.ler_valor("3") == 300
for invalido in ("abc", "0", "-5"):
try:
despesas.ler_valor(invalido)
except ValueError:
pass
else:
raise AssertionError(f"{invalido!r} deveria falhar")
def testar_formatar_reais():
assert despesas.formatar_reais(1250) == "R$ 12,50"
assert despesas.formatar_reais(123456) == "R$ 1.234,56"
assert despesas.formatar_reais(5) == "R$ 0,05"
def testar_adicionar_e_total():
lista = []
despesas.adicionar(lista, "Almoço", "32,90", "alimentação", "2026-10-06")
despesas.adicionar(lista, "Jantar", "20,00", "alimentação", "2026-10-06")
despesas.adicionar(lista, "Ônibus", "4,50", "transporte", "2026-10-06")
assert despesas.total_por_categoria(lista) == {"alimentação": 5290, "transporte": 450}
def testar_adicionar_recusa_dados_invalidos():
lista = []
for argumentos in (("", "10", "lazer"), ("Cinema", "10", "inexistente"), ("Cinema", "x", "lazer")):
try:
despesas.adicionar(lista, *argumentos)
except ValueError:
pass
else:
raise AssertionError(f"{argumentos} deveria falhar")
assert lista == []
def testar_remover():
lista = [{"descricao": "a", "centavos": 1, "categoria": "outros", "data": "x"}]
assert despesas.remover(lista, 1)["descricao"] == "a"
assert lista == []
try:
despesas.remover(lista, 1)
except ValueError:
pass
else:
raise AssertionError("deveria falhar")
def testar_salvar_e_carregar():
with tempfile.TemporaryDirectory() as pasta:
caminho = Path(pasta) / "d.json"
assert despesas.carregar(caminho) == []
dados = [{"descricao": "Pão", "centavos": 850, "categoria": "alimentação", "data": "2026-10-06"}]
despesas.salvar(caminho, dados)
assert despesas.carregar(caminho) == dados
if __name__ == "__main__":
testes = [nome for nome in dir() if nome.startswith("testar_") and callable(globals()[nome])]
for nome in testes:
globals()[nome]()
print(f"ok {nome}")
print(f"{len(testes)} testes passaram")
Rodar
cd projetos/despesas
python3 testar_despesas.py
ok testar_adicionar_e_total
ok testar_adicionar_recusa_dados_invalidos
ok testar_formatar_reais
ok testar_ler_valor
ok testar_remover
ok testar_salvar_e_carregar
6 testes passaram
python3 despesas.py
Uma sessão real, depois de registrar um almoço, um ônibus e um jantar (a tentativa com o valor abc foi recusada com a mensagem Não foi possível adicionar: valor inválido: 'abc'):
1. 2026-10-06 Almoço R$ 32,90 [alimentação]
2. 2026-10-06 Ônibus R$ 4,50 [transporte]
3. 2026-10-06 Jantar R$ 20,00 [alimentação]
alimentação R$ 52,90
transporte R$ 4,50
total R$ 57,40
Desafios, em ordem de dificuldade
- Filtrar por mês. Peça um mês (
2026-10) e liste só as despesas dele, usando o campodatacomo texto. - Orçamento. Guarde um limite mensal por categoria e avise, no resumo, quando uma categoria passou dele.
- Exportar para CSV. Use o módulo
csv(capítulo 39) para gerardespesas.csvcom cabeçalho. - Trocar o menu por argumentos. Reescreva a interface com
argparse(capítulo 41), de modo quepython3 despesas.py adicionar "Almoço" 32,90 alimentaçãofuncione sem menu.
Quando considero o projeto pronto
Se eu apago o
despesas.jsone o programa continua funcionando, se uma entrada inválida nunca derruba o programa, e se o autoteste passa depois de qualquer mudança que eu faça, o projeto está pronto. O passo seguinte, no capítulo 64, é reescrevê-lo como um pacote de verdade.