Skip to content

    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.

    RequirementConcept that trains
    Record description, value, and categoryFunctions, validation, exceptions
    Store the value in centsTypes (never float for money)
    Persist in despesas.jsonFiles, encoding, json
    Summary by categoryDictionaries, sorting
    Reject invalid input without crashingtry/except with clear messages
    An autotest fileassert, 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,50 becomes 1250. Add 0,10 + 0,20 into float does not work 0,30, and in a financial system this is a bug.
    • Validate on input. adicionar it rejects empty description, unknown category, and non-positive value, and main shows the reason instead of crashing.
    • Save on every change. If the program closes suddenly, nothing is lost.

    The code

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

    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:

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

    Run

    Terminal
    cd projetos/despesas
    python3 testar_despesas.py
    
    Output
    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
    

    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'):

    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
    

    Challenges, in order of difficulty

    1. Filter by month. Request a month (2026-10) and list only the expenses for it, using the field data as text.
    2. Budget. Keep a monthly limit per category and notify, in the summary, when a category exceeds it.
    3. Export to CSV. Use the module csv (chapter 39) to generate despesas.csv with a header.
    4. Replace the menu with arguments. Rewrite the interface with argparse (chapter 41), so that python3 despesas.py adicionar "Almoço" 32,90 alimentação works without a menu.

    When I consider the project finished

    If I delete the despesas.json and 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.