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.
| Requisito | Conceito que treina | Capítulo |
|---|---|---|
Modelo Tarefa | dataclass, Self, asdict | 36, 37 |
| Armazenamento trocável (JSON ou memória) | Protocol, composição | 44 |
| Regras isoladas em um serviço | Camadas, exceções do domínio | 23, 51 |
Subcomandos com argparse | CLI testável | 41 |
Logs com -v | logging | 41 |
| Testes com pytest | Fixtures, tmp_path, capsys | 40 |
| Pacote com comando próprio | pyproject.toml, entry points | 49 |
A arquitetura em três camadas
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:
[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:
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:
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:
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:
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
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:
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:
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
cd projetos/tarefas
uv sync
uv run pytest
uv run mypy
uv run ruff check .
........ [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):
uv run tarefas adicionar "Estudar Python"
uv run tarefas adicionar "Escrever testes"
uv run tarefas concluir 1
uv run tarefas listar
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:
uv run tarefas -v adicionar "com log"
uv run tarefas concluir 99
INFO tarefas.servico: tarefa 3 adicionada
Tarefa 3 criada: com log
Erro: tarefa 99 não encontrada
Desafios
- Prioridade e prazo. Acrescente
prioridadeeprazoàTarefae 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 emde_dict). - Armazenamento SQLite. Escreva
ArmazenamentoSqlitecom 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. - Desfazer. Um comando
desfazerque reverte a última operação. - Publicar. Gere o wheel com
uv builde 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.