Pular para o conteúdo

    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.

    RequisitoConceito que treina
    Registrar descrição, valor e categoriaFunções, validação, exceções
    Guardar o valor em centavosTipos (nunca float para dinheiro)
    Persistir em despesas.jsonArquivos, encoding, json
    Resumo por categoriaDicionários, ordenação
    Recusar entrada inválida sem cairtry/except com mensagens claras
    Um arquivo de autotesteassert, 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,50 vira 1250. Somar 0,10 + 0,20 em float não dá 0,30, e em um sistema financeiro isso é bug.
    • Validar na entrada. adicionar recusa descrição vazia, categoria desconhecida e valor não positivo, e main mostra o motivo em vez de quebrar.
    • Gravar a cada mudança. Se o programa fechar de repente, nada se perde.

    O código

    projetos/despesas/despesas.py
    """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:

    projetos/despesas/testar_despesas.py
    """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

    Terminal
    cd projetos/despesas
    python3 testar_despesas.py
    
    Saída
    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
    
    Terminal
    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'):

    Sessão real
    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

    1. Filtrar por mês. Peça um mês (2026-10) e liste só as despesas dele, usando o campo data como texto.
    2. Orçamento. Guarde um limite mensal por categoria e avise, no resumo, quando uma categoria passou dele.
    3. Exportar para CSV. Use o módulo csv (capítulo 39) para gerar despesas.csv com cabeçalho.
    4. Trocar o menu por argumentos. Reescreva a interface com argparse (capítulo 41), de modo que python3 despesas.py adicionar "Almoço" 32,90 alimentação funcione sem menu.

    Quando considero o projeto pronto

    Se eu apago o despesas.json e 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.