Pular para conteúdo

Padrões visuais Rich

Parte da norma Padronização de CLI para Fetchers. Os números de seção (§) são os da norma completa.

4.1 Paleta de cores e estilos

Todos os fetchers usam a seguinte paleta semântica:

Markup Significado Quando usar
[cyan] Ação em andamento, identificadores Nome de grupos sendo processados, títulos de tabelas
[green] Sucesso Confirmações de download, checkmarks ✓
[red] Erro Mensagens de erro, valores inválidos
[yellow] Aviso Itens pulados, validações não-fatais
[bold] Ênfase Números importantes, caminhos de arquivo
[dim] Secundário Caminhos longos, timestamps, contadores de baixa relevância
[bold green] Sucesso com ênfase Contadores finais positivos
[bold red] Erro com ênfase Contadores de falha

4.2 console.status — operação única de rede

Use para operações que não têm progresso mensurável (uma única requisição HTTP, uma conexão FTP):

with console.status("[cyan]Conectando ao FTP do DATASUS...[/cyan]"):
    ftp = fetcher.connect()

with console.status(f"[cyan]Baixando metadados da série {series_id}...[/cyan]"):
    htmls = scraper.request_metadata_html(series_id=series_id)

with console.status("[cyan]Buscando metadados RTN...[/cyan]"):
    metadata_html = fetch_publications_metadata()

O spinner aparece automaticamente enquanto o bloco with estiver ativo. Não use console.status para operações com progresso conhecido — use Progress nesse caso.

4.3 Progress — barras de progresso para operações bulk

Para operações com N itens conhecidos ou estimáveis:

from rich.progress import (
    BarColumn,
    MofNCompleteColumn,
    Progress,
    SpinnerColumn,
    TextColumn,
    TimeElapsedColumn,
    TimeRemainingColumn,
)

def _make_progress() -> Progress:
    """Cria uma Progress bar padronizada para operações bulk."""
    return Progress(
        SpinnerColumn(),
        TextColumn("[progress.description]{task.description}"),
        BarColumn(),
        MofNCompleteColumn(),
        TimeElapsedColumn(),
        TimeRemainingColumn(),
        console=console,
    )

Sempre passe console=console para que o Progress use o mesmo objeto que o restante do plugin — sem isso, Rich pode abrir um novo console e o intercalamento com logs (RichHandler) fica incorreto.

Progresso com total conhecido

with _make_progress() as progress:
    task = progress.add_task("[cyan]Baixando metadados...[/cyan]", total=len(ids))

    def on_progress(processed, total, ok, failed, skipped):
        progress.update(
            task,
            completed=processed,
            description=(
                f"[green]{ok}✓[/green]"
                f"  [red]{failed}✗[/red]"
                f"  [dim]{skipped} skip[/dim]"
            ),
        )

    resultado = bulk.fetch_metadata_bulk(..., on_progress=on_progress)

Progresso indeterminado (total desconhecido inicialmente)

Use total=None para iniciar indeterminado e atualize quando o total for descoberto:

with Progress(
    SpinnerColumn(),
    TextColumn("[progress.description]{task.description}"),
    MofNCompleteColumn(),
    TimeElapsedColumn(),
    console=console,
) as progress:
    task = progress.add_task("[cyan]Iniciando...[/cyan]", total=None)

    def on_grupo(nome: str, done: int, total: int) -> None:
        progress.update(
            task,
            completed=done,
            total=total,
            description=f"[cyan]{nome[:40]}[/cyan]",
        )

    bulk.fetch_arvore_grupos(..., on_grupo=on_grupo)

Downloads Paralelos e Progresso Concorrente

Para fetchers que baixam múltiplos arquivos onde é razoável paralelizá-los, adote o concurrent.futures.ThreadPoolExecutor e um pool de barras concorrentes fixo renderizadas via make_download_progress. Este padrão evita a poluição do console (quando há centenas de arquivos) ao reciclar as barras de progresso ativas. Exija um argumento --workers (padrão 4) na CLI:

import concurrent.futures
import threading
from quantilica.cli.ui import make_download_progress

# No comando:
lock = threading.Lock()

with make_download_progress(console=console) as progress:
    # 1. Pré-aloca exatamente `workers` barras invisíveis/inativas
    worker_task_ids = [
        progress.add_task("[dim]Inativo[/dim]", total=1) for _ in range(workers)
    ]
    available_tasks = worker_task_ids.copy()

    def _worker(entry: dict) -> bool:
        # 2. Worker adquire uma barra do pool ao iniciar
        with lock:
            task_id = available_tasks.pop(0)

        # Atualiza a barra imediatamente com o nome do arquivo
        progress.update(task_id, description=f"[cyan]{entry['id']}[/cyan]", completed=0, total=None)

        def on_bytes(downloaded: int, total: int) -> None:
            if downloaded == 0 and total == 0:
                progress.update(task_id, completed=0)
                return
            progress.update(
                task_id,
                completed=downloaded,
                total=total or None,
            )

        try:
            download_entry(entry, progress=on_bytes)
            return True
        finally:
            # 3. Limpa a barra e devolve ao pool
            with lock:
                progress.update(task_id, description="[dim]Inativo[/dim]", completed=0, total=1)
                available_tasks.append(task_id)

    executor = concurrent.futures.ThreadPoolExecutor(max_workers=workers)
    futures = {}
    try:
        futures = {executor.submit(_worker, e): e for e in entries}
        for future in concurrent.futures.as_completed(futures):
            # atualizar barra geral, lidar com resultados, etc.
            pass
        executor.shutdown(wait=True)
    except KeyboardInterrupt:
        for future in futures:
            future.cancel()
        executor.shutdown(wait=False, cancel_futures=True)
        raise

Isso garante o uso ótimo de rede e uma experiência interativa rica, sem poluir o terminal, enquanto os retornos globais (ok/falha) podem ser controlados por um make_batch_progress ou log à parte. Para casos nativamente assíncronos (asyncio), como o tesouro-direto-fetcher ou rtn-fetcher, aplique semáforos assíncronos e lógica similar de pop/append na lista available_tasks.

4.4 Table — exibição de dados tabulares

from rich.table import Table

# Padrão mínimo
table = Table(show_header=True, header_style="bold")
table.add_column("ID", style="cyan", justify="right")
table.add_column("Nome", style="green")
table.add_column("Tamanho", justify="right")

for item in items:
    table.add_row(str(item.id), item.nome, f"{item.size / 2**20:.1f} MB")

console.print(table)

Convenções:

  • header_style="bold" como padrão; use "bold magenta" ou "bold yellow" para tabelas secundárias.
  • IDs numéricos: style="cyan", justify="right".
  • Nomes e textos longos: style="green", justificação padrão (left).
  • Números e tamanhos: justify="right".
  • Metadados de baixa relevância: style="dim".
  • Totais em rodapé: imprimir separadamente com console.print(f"[bold]Total:[/bold] ...") após a tabela.

Exemplo completo do datasus-fetcher:

t = Table(show_header=True, header_style="bold")
t.add_column("Dataset", style="cyan")
t.add_column("Arquivos", justify="right")
t.add_column("Tamanho", justify="right")

for dataset in sorted(targets):
    t.add_row(dataset, str(n), f"{size / 2**20:.1f} MB")

console.print(t)
console.print(f"[bold]Total:[/bold] {total_files} arquivos, {total_size / 2**30:.1f} GB")

4.5 Panel — metadados de uma entidade

Use para exibir informações detalhadas de um único item após busca:

from rich.panel import Panel

lines = []
if basic.name:
    lines.append(f"[bold]{basic.name}[/bold]")
if basic.frequency:
    lines.append(f"Periodicidade: [cyan]{basic.frequency}[/cyan]")
if basic.unit:
    lines.append(f"Unidade: [cyan]{basic.unit}[/cyan]")
if basic.start_date or basic.end_date:
    lines.append(f"Período: [dim]{basic.start_date} → {basic.end_date}[/dim]")

console.print(Panel("\n".join(lines), title=f"Série {series_id}"))

Exemplo do sidra-fetcher:

console.print(
    Panel(
        f"[bold cyan]{metadados.nome}[/bold cyan]\n[dim]{metadados.assunto}[/dim]",
        title=f"Agregado {metadados.id}",
    )
)

4.6 Rule — divisores de seção em pipelines

Use para separar visualmente passos de pipelines longos:

from rich.rule import Rule

console.print(Rule("[bold]Passo 1/4: Árvore de grupos[/bold]"))
# ... execução do passo ...
console.print(Rule("[bold]Passo 2/4: Séries desativadas[/bold]"))

Substitui os antigos typer.echo("=== Passo N/4 ===").

console.rule("[bold]texto[/bold]") é equivalente a console.print(Rule("[bold]texto[/bold]")) e pode ser preferido pela brevidade.

4.7 Mensagens de resultado padronizadas

Mensagem de sucesso simples:

console.print(f"[green]✓[/green] Salvo em [dim]{outfile}[/dim]")
console.print(f"[green]✓[/green] [bold]{len(items)}[/bold] itens baixados.")

Sucesso com contadores (bulk):

if failed_count:
    console.print(
        f"[yellow]⚠[/yellow]  {successful} OK · [red]{failed_count} falha(s)[/red]"
    )
else:
    console.print(
        f"[green]✓[/green]  [bold]{successful}[/bold] itens processados com sucesso."
    )

Erro fatal (antes de raise typer.Exit(code=1)):

console.print(f"[red]Erro:[/red] {mensagem_de_erro}")
raise typer.Exit(code=1)

Aviso não-fatal:

console.print(f"[yellow]Aviso:[/yellow] '{item}' não encontrado, pulando.")

Nenhum resultado:

console.print("[yellow]Nenhum resultado encontrado.[/yellow]")