Pular para o conteúdo

    Capítulo 64, Projetos

    Projeto Intermediário: CLI de tarefas

    Aqui você escreve o mesmo tipo de programa do projeto anterior, agora como um profissional escreveria: um pacote instalável, com camadas, tipos, testes e logs. Faça depois do capítulo 41.

    Os arquivos deste capítulo estão em projetos/tarefas/.

    O que você vai construir

    Uma CLI tarefas com os subcomandos adicionar, listar, concluir e remover, instalável como um comando de terminal. A diferença para o projeto Básico não está no que ela faz, e sim em como está organizada.

    RequisitoConceito que treinaCapítulo
    Modelo Tarefadataclass, Self, asdict36, 37
    Armazenamento trocável (JSON ou memória)Protocol, composição44
    Regras isoladas em um serviçoCamadas, exceções do domínio23, 51
    Subcomandos com argparseCLI testável41
    Logs com -vlogging41
    Testes com pytestFixtures, tmp_path, capsys40
    Pacote com comando própriopyproject.toml, entry points49

    A arquitetura em três camadas

    Quem conhece quem
    cli.py            lê argumentos, imprime, devolve o código de saída
      servico.py      regras: título não vazio, ids sequenciais, tarefa inexistente
        armazenamento.py   JSON em disco, ou memória (para testes)
          modelo.py        a dataclass Tarefa
    

    O serviço recebe o armazenamento no construtor. Por isso os testes do serviço usam o ArmazenamentoEmMemoria, rápido e sem tocar o disco, e os testes de integração usam o ArmazenamentoJson com uma pasta temporária. Trocar o JSON por SQLite amanhã exige escrever uma classe nova, e nada mais muda.

    O pacote

    O pyproject.toml declara o comando tarefas em [project.scripts]. Depois de instalado, tarefas listar funciona em qualquer pasta:

    projetos/tarefas/pyproject.toml
    [build-system]
    requires = ["hatchling"]
    build-backend = "hatchling.build"
    
    [project]
    name = "tarefas"
    version = "0.1.0"
    description = "Gerenciador de tarefas em linha de comando (projeto da parte Intermediário)"
    readme = "README.md"
    requires-python = ">=3.12"
    dependencies = []
    
    [project.scripts]
    tarefas = "tarefas.cli:main"
    
    [dependency-groups]
    dev = ["pytest>=8", "mypy>=1.10", "ruff>=0.6"]
    
    [tool.ruff]
    line-length = 100
    target-version = "py312"
    
    [tool.ruff.lint]
    select = ["E", "F", "I", "B", "UP"]
    
    [tool.mypy]
    python_version = "3.12"
    strict = true
    files = ["src"]
    

    O código

    O modelo é uma dataclass com conversão de e para dicionário:

    projetos/tarefas/src/tarefas/modelo.py
    from dataclasses import asdict, dataclass, field
    from datetime import UTC, datetime
    from typing import Any, Self
    
    
    def agora_iso() -> str:
        return datetime.now(UTC).isoformat(timespec="seconds")
    
    
    @dataclass
    class Tarefa:
        id: int
        titulo: str
        concluida: bool = False
        criada_em: str = field(default_factory=agora_iso)
    
        def para_dict(self) -> dict[str, Any]:
            return asdict(self)
    
        @classmethod
        def de_dict(cls, dados: dict[str, Any]) -> Self:
            return cls(**dados)
    

    O armazenamento é descrito por um Protocol, com duas implementações. Repare no salvar do JSON: ele grava em um arquivo temporário e depois troca, e assim uma queda no meio da gravação nunca deixa o arquivo corrompido:

    projetos/tarefas/src/tarefas/armazenamento.py
    import json
    from pathlib import Path
    from typing import Protocol
    
    from tarefas.modelo import Tarefa
    
    
    class Armazenamento(Protocol):
        def carregar(self) -> list[Tarefa]: ...
    
        def salvar(self, tarefas: list[Tarefa]) -> None: ...
    
    
    class ArmazenamentoJson:
        def __init__(self, caminho: Path) -> None:
            self._caminho = caminho
    
        def carregar(self) -> list[Tarefa]:
            if not self._caminho.exists():
                return []
            dados = json.loads(self._caminho.read_text(encoding="utf-8"))
            return [Tarefa.de_dict(item) for item in dados]
    
        def salvar(self, tarefas: list[Tarefa]) -> None:
            texto = json.dumps([t.para_dict() for t in tarefas], ensure_ascii=False, indent=2)
            # Grava em um arquivo temporário e troca no final: se o programa cair no meio,
            # o arquivo original continua íntegro.
            temporario = self._caminho.with_suffix(".tmp")
            temporario.write_text(texto, encoding="utf-8")
            temporario.replace(self._caminho)
    
    
    class ArmazenamentoEmMemoria:
        """Usado nos testes: não toca o disco."""
    
        def __init__(self) -> None:
            self._tarefas: list[Tarefa] = []
    
        def carregar(self) -> list[Tarefa]:
            return [Tarefa.de_dict(t.para_dict()) for t in self._tarefas]
    
        def salvar(self, tarefas: list[Tarefa]) -> None:
            self._tarefas = [Tarefa.de_dict(t.para_dict()) for t in tarefas]
    

    O serviço concentra as regras e registra o que fez em logs:

    projetos/tarefas/src/tarefas/servico.py
    import logging
    
    from tarefas.armazenamento import Armazenamento
    from tarefas.modelo import Tarefa
    
    log = logging.getLogger(__name__)
    
    
    class TarefaNaoEncontrada(Exception):
        def __init__(self, tarefa_id: int) -> None:
            super().__init__(f"tarefa {tarefa_id} não encontrada")
            self.tarefa_id = tarefa_id
    
    
    class ServicoTarefas:
        def __init__(self, armazenamento: Armazenamento) -> None:
            self._armazenamento = armazenamento
    
        def adicionar(self, titulo: str) -> Tarefa:
            titulo = titulo.strip()
            if not titulo:
                raise ValueError("o título não pode ser vazio")
            tarefas = self._armazenamento.carregar()
            proximo_id = max((t.id for t in tarefas), default=0) + 1
            tarefa = Tarefa(id=proximo_id, titulo=titulo)
            self._armazenamento.salvar([*tarefas, tarefa])
            log.info("tarefa %d adicionada", tarefa.id)
            return tarefa
    
        def listar(self, *, somente_pendentes: bool = False) -> list[Tarefa]:
            tarefas = self._armazenamento.carregar()
            return [t for t in tarefas if not t.concluida] if somente_pendentes else tarefas
    
        def concluir(self, tarefa_id: int) -> Tarefa:
            tarefas = self._armazenamento.carregar()
            tarefa = self._buscar(tarefas, tarefa_id)
            tarefa.concluida = True
            self._armazenamento.salvar(tarefas)
            log.info("tarefa %d concluída", tarefa_id)
            return tarefa
    
        def remover(self, tarefa_id: int) -> None:
            tarefas = self._armazenamento.carregar()
            tarefa = self._buscar(tarefas, tarefa_id)
            tarefas.remove(tarefa)
            self._armazenamento.salvar(tarefas)
            log.info("tarefa %d removida", tarefa_id)
    
        @staticmethod
        def _buscar(tarefas: list[Tarefa], tarefa_id: int) -> Tarefa:
            for tarefa in tarefas:
                if tarefa.id == tarefa_id:
                    return tarefa
            raise TarefaNaoEncontrada(tarefa_id)
    

    A CLI só traduz entre o terminal e o serviço. A função main recebe argv e o armazenamento como parâmetros (padrão do capítulo 41), e devolve um código de saída: 0 para sucesso, 1 para erro do usuário. O argparse já devolve 2 para argumentos inválidos:

    projetos/tarefas/src/tarefas/cli.py
    import argparse
    import logging
    from pathlib import Path
    
    from tarefas.armazenamento import Armazenamento, ArmazenamentoJson
    from tarefas.servico import ServicoTarefas, TarefaNaoEncontrada
    
    
    def criar_parser() -> argparse.ArgumentParser:
        parser = argparse.ArgumentParser(prog="tarefas", description="Gerenciador de tarefas")
        parser.add_argument("--arquivo", type=Path, default=Path("tarefas.json"))
        parser.add_argument("-v", "--verboso", action="store_true", help="mostra os logs")
        sub = parser.add_subparsers(dest="comando", required=True)
    
        adicionar = sub.add_parser("adicionar", help="cria uma tarefa")
        adicionar.add_argument("titulo")
    
        listar = sub.add_parser("listar", help="mostra as tarefas")
        listar.add_argument("--pendentes", action="store_true")
    
        concluir = sub.add_parser("concluir", help="marca como concluída")
        concluir.add_argument("id", type=int)
    
        remover = sub.add_parser("remover", help="apaga uma tarefa")
        remover.add_argument("id", type=int)
        return parser
    
    
    def main(argv: list[str] | None = None, armazenamento: Armazenamento | None = None) -> int:
        args = criar_parser().parse_args(argv)
        logging.basicConfig(
            level=logging.INFO if args.verboso else logging.WARNING,
            format="%(levelname)s %(name)s: %(message)s",
            force=True,
        )
        servico = ServicoTarefas(armazenamento or ArmazenamentoJson(args.arquivo))
        try:
            if args.comando == "adicionar":
                tarefa = servico.adicionar(args.titulo)
                print(f"Tarefa {tarefa.id} criada: {tarefa.titulo}")
            elif args.comando == "listar":
                tarefas = servico.listar(somente_pendentes=args.pendentes)
                if not tarefas:
                    print("Nenhuma tarefa.")
                for t in tarefas:
                    marca = "x" if t.concluida else " "
                    print(f"[{marca}] {t.id}. {t.titulo}")
            elif args.comando == "concluir":
                print(f"Tarefa {servico.concluir(args.id).id} concluída.")
            elif args.comando == "remover":
                servico.remover(args.id)
                print(f"Tarefa {args.id} removida.")
        except (TarefaNaoEncontrada, ValueError) as erro:
            print(f"Erro: {erro}")
            return 1
        return 0
    
    projetos/tarefas/src/tarefas/__main__.py
    from tarefas.cli import main
    
    raise SystemExit(main())
    

    Os testes

    Os testes de serviço usam o armazenamento em memória e a fixture servico. O teste do armazenamento JSON confirma a ida e a volta pelo disco e que o arquivo temporário não sobra:

    projetos/tarefas/tests/test_servico.py
    import pytest
    
    from tarefas.armazenamento import ArmazenamentoEmMemoria, ArmazenamentoJson
    from tarefas.servico import ServicoTarefas, TarefaNaoEncontrada
    
    
    @pytest.fixture
    def servico() -> ServicoTarefas:
        return ServicoTarefas(ArmazenamentoEmMemoria())
    
    
    def test_adicionar_gera_ids_sequenciais(servico: ServicoTarefas) -> None:
        assert servico.adicionar("a").id == 1
        assert servico.adicionar("b").id == 2
    
    
    def test_titulo_vazio_e_recusado(servico: ServicoTarefas) -> None:
        with pytest.raises(ValueError, match="vazio"):
            servico.adicionar("   ")
    
    
    def test_concluir_e_filtrar_pendentes(servico: ServicoTarefas) -> None:
        servico.adicionar("a")
        servico.adicionar("b")
        servico.concluir(1)
        assert [t.titulo for t in servico.listar(somente_pendentes=True)] == ["b"]
        assert len(servico.listar()) == 2
    
    
    def test_remover_e_nao_reutilizar_apos_remocao_do_ultimo(servico: ServicoTarefas) -> None:
        servico.adicionar("a")
        servico.remover(1)
        assert servico.listar() == []
        with pytest.raises(TarefaNaoEncontrada):
            servico.remover(1)
    
    
    def test_armazenamento_json_ida_e_volta(tmp_path) -> None:  # type: ignore[no-untyped-def]
        caminho = tmp_path / "t.json"
        ServicoTarefas(ArmazenamentoJson(caminho)).adicionar("persistida")
        outra_instancia = ServicoTarefas(ArmazenamentoJson(caminho))
        assert [t.titulo for t in outra_instancia.listar()] == ["persistida"]
        assert not caminho.with_suffix(".tmp").exists()
    

    Os testes da CLI chamam main diretamente, com capsys para capturar a saída e tmp_path para isolar o arquivo. Um deles confirma o código de saída 2 do argparse:

    projetos/tarefas/tests/test_cli.py
    from pathlib import Path
    
    import pytest
    
    from tarefas.cli import main
    
    
    def rodar(arquivo: Path, *argumentos: str) -> int:
        return main(["--arquivo", str(arquivo), *argumentos])
    
    
    def test_fluxo_completo(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
        arquivo = tmp_path / "t.json"
        assert rodar(arquivo, "adicionar", "Estudar Python") == 0
        assert rodar(arquivo, "adicionar", "Escrever testes") == 0
        assert rodar(arquivo, "concluir", "1") == 0
        capsys.readouterr()
        assert rodar(arquivo, "listar") == 0
        saida = capsys.readouterr().out
        assert "[x] 1. Estudar Python" in saida
        assert "[ ] 2. Escrever testes" in saida
    
    
    def test_tarefa_inexistente_devolve_codigo_1(
        tmp_path: Path, capsys: pytest.CaptureFixture[str]
    ) -> None:
        assert rodar(tmp_path / "t.json", "concluir", "99") == 1
        assert "Erro: tarefa 99 não encontrada" in capsys.readouterr().out
    
    
    def test_argumento_invalido_sai_com_codigo_2(tmp_path: Path) -> None:
        with pytest.raises(SystemExit) as codigo:
            rodar(tmp_path / "t.json", "concluir", "abc")
        assert codigo.value.code == 2
    

    Rodar

    Terminal
    cd projetos/tarefas
    uv sync
    uv run pytest
    uv run mypy
    uv run ruff check .
    
    Saída
    ........                                                                 [100%]
    8 passed
    Success: no issues found in 6 source files
    All checks passed!
    

    Usando a CLI (o primeiro argumento antes do subcomando escolhe o arquivo):

    Terminal
    uv run tarefas adicionar "Estudar Python"
    uv run tarefas adicionar "Escrever testes"
    uv run tarefas concluir 1
    uv run tarefas listar
    
    Saída
    Tarefa 1 criada: Estudar Python
    Tarefa 2 criada: Escrever testes
    Tarefa 1 concluída.
    [x] 1. Estudar Python
    [ ] 2. Escrever testes
    

    Com -v, o serviço mostra o que fez, e um erro do usuário não gera traceback, só uma mensagem e o código 1:

    Terminal
    uv run tarefas -v adicionar "com log"
    uv run tarefas concluir 99
    
    Saída
    INFO tarefas.servico: tarefa 3 adicionada
    Tarefa 3 criada: com log
    Erro: tarefa 99 não encontrada
    

    Desafios

    1. Prioridade e prazo. Acrescente prioridade e prazo à Tarefa e ordene a listagem por eles. Isso muda o modelo e os arquivos já gravados: pense em como ler um JSON antigo sem esses campos (um valor padrão em de_dict).
    2. Armazenamento SQLite. Escreva ArmazenamentoSqlite com a mesma interface e rode os mesmos testes do serviço contra ele. Se você precisar mudar o serviço para isso, a abstração estava vazando.
    3. Desfazer. Um comando desfazer que reverte a última operação.
    4. Publicar. Gere o wheel com uv build e instale-o em um ambiente limpo, como o capítulo 49 mostrou.

    O que este projeto ensina que o anterior não ensinava

    No projeto Básico, tudo estava em um arquivo e era testado por uma função caseira. Aqui cada responsabilidade tem um lugar, as dependências são injetadas, o programa é testável sem terminal e o comando é instalável. Essa é a diferença entre um script e um pacote.