Chapter 63, Projects
Basic Project: expense manager
The small exercises reinforce the syntax. A real project teaches something else: deciding what goes in each function, what persists, and what happens when the input is bad. Do this after chapter 25.
The files for this chapter are in projetos/despesas/.
What you will build
A terminal program to record expenses, list them, see the total by category, and remove them. The data is stored in a file and survives between executions. Everything uses what the Basic part taught (functions, lists, dictionaries, exceptions, files, modules), plus the module json, which chapter 39 details.
| Requirement | Concept that trains |
|---|---|
| Record description, value, and category | Functions, validation, exceptions |
| Store the value in cents | Types (never float for money) |
Persist in despesas.json | Files, encoding, json |
| Summary by category | Dictionaries, sorting |
| Reject invalid input without crashing | try/except with clear messages |
| An autotest file | assert, functions as objects |
How I would think before typing
Separate what calculates of the what interacts with the user. The functions ler_valor, adicionar, total_por_categoria and remover do not use input or print: they receive data and return data (or raise ValueError). They only mostrar_lista, mostrar_resumo and main interact. This division is what allows testing them without typing anything, and it is the same idea of separating the rule from the external world that reappears even in chapter 51.
Three decisions worth noting in the code:
- Whole cents.
12,50becomes1250. Add0,10 + 0,20intofloatdoes not work0,30, and in a financial system this is a bug. - Validate on input.
adicionarit rejects empty description, unknown category, and non-positive value, andmainshows the reason instead of crashing. - Save on every change. If the program closes suddenly, nothing is lost.
The code
"""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()
The self-test
Without pytest yet (it only appears in chapter 40), the test uses assert and a small convention: every name that starts with testar_ is a test function, and the final loop executes them. Note that it uses a temporary folder, and therefore never touches your despesas.json for real:
"""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")
Run
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
A real session, after recording a lunch, a bus, and a dinner (the attempt with the value abc was rejected with the message 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
Challenges, in order of difficulty
- Filter by month. Request a month (
2026-10) and list only the expenses for it, using the fielddataas text. - Budget. Keep a monthly limit per category and notify, in the summary, when a category exceeds it.
- Export to CSV. Use the module
csv(chapter 39) to generatedespesas.csvwith a header. - Replace the menu with arguments. Rewrite the interface with
argparse(chapter 41), so thatpython3 despesas.py adicionar "Almoço" 32,90 alimentaçãoworks without a menu.
When I consider the project finished
If I delete the
despesas.jsonand the program continues to work, if an invalid input never crashes the program, and if the autotest passes after any change I make, the project is ready. The next step, in chapter 64, is to rewrite it as a real package.