Skill: olegantonov/tse-eleicoes (version 1.0.0)

--- name: tse-eleicoes description: > Cliente Python assíncrono para as APIs do TSE (Tribunal Superior Eleitoral). Integra DivulgaCandContas REST (candidatos, eleições, prestação de contas em tempo real) e CKAN Dados Abertos (datasets para download em massa). 30+ métodos cobrindo: eleições ordinárias e suplementares, candidatos (detalhe, bens, foto, redes sociais, filtros por partido e situação), prestação de contas, ranking de doadores, datasets CKAN e URLs de download. Sem autenticação. Python 3.11+, httpx, retry com backoff exponencial. version: 1.0.0 author: olegantonov license: MIT homepage: https://clawhub.ai repository: https://github.com/olegantonov/tse-eleicoes tags: - tse - eleicoes - candidatos - brasil - dados-abertos - api - async --- # SKILL: tse-eleicoes Use esta skill para acessar dados eleitorais do TSE (Tribunal Superior Eleitoral) diretamente via Python assíncrono. ## Quando Usar - Buscar informações sobre candidatos (bens, redes sociais, foto, partido, situação) - Listar eleições ordinárias ou suplementares por estado/município - Consultar prestação de contas e ranking de doadores de campanha - Baixar datasets oficiais (candidatos, resultados, eleitorado) via CKAN ## Como Usar ```python import asyncio from tse_client import get_tse_client, ELEICOES_ORDINARIAS, nome_cargo async def main(): client = get_tse_client() try: # Eleições disponíveis anos = await client.get_anos_eleitorais() # Candidatos a prefeito em SP — Municipais 2024 candidatos = await client.get_candidatos( ano=2024, municipio_cod="71072", eleicao_id=ELEICOES_ORDINARIAS["2024"], cargo_cod="11" ) # Dados abertos datasets = await client.get_datasets_candidatos() url_zip = await client.get_url_download_candidatos(2024) finally: await client.close() asyncio.run(main()) ``` ## Instalação ```bash git clone https://github.com/olegantonov/tse-eleicoes.git cd tse-eleicoes pip install -r requirements.txt ``` ## APIs Integradas - **DivulgaCandContas**: `https://divulgacandcontas.tse.jus.br/divulga/rest/v1` - **CKAN Dados Abertos**: `https://dadosabertos.tse.jus.br/api/3/action` ## Principais Métodos ### Eleições - `get_eleicoes_ordinarias()` — lista eleições ordinárias - `get_anos_eleitorais()` — anos disponíveis - `get_cargos_municipio(eleicao_id, municipio_cod)` — cargos por município - `get_eleicoes_suplementares(ano, uf)` — eleições suplementares ### Candidatos - `get_candidatos(ano, municipio_cod, eleicao_id, cargo_cod)` — lista candidatos - `get_candidato(ano, municipio_cod, eleicao_id, candidato_id)` — detalhe completo - `buscar_candidato_por_nome(nome, ...)` — busca por nome - `get_bens_candidato(...)` — bens declarados - `get_candidatos_eleitos(...)` — só eleitos - `get_candidatos_por_partido(sigla, ...)` — filtra por partido ### Prestação de Contas - `get_prestacao_contas(eleicao_id, ano, municipio_cod, cargo_cod, candidato_id)` - `get_ranking_doadores(...)` — top doadores ordenados por valor ### Dados Abertos (CKAN) - `get_datasets()` — lista todos os datasets - `buscar_datasets(termo)` — busca por palavra-chave - `get_datasets_candidatos()` / `get_datasets_resultados()` / `get_datasets_eleitorado()` - `get_url_download_candidatos(ano)` / `get_url_download_resultados(ano)` — URLs dos ZIPs ## Constantes Úteis ```python from tse_client import ELEICOES_ORDINARIAS, CARGOS, nome_cargo, nome_eleicao ELEICOES_ORDINARIAS # {'2024': '2045202024', '2022': '2040602022', ...} CARGOS # {'11': 'Prefeito', '13': 'Vereador', ...} nome_cargo("11") # "Prefeito" nome_eleicao("2045202024") # "2024" ```

path: README.md
content: # TSE Eleições - Cliente Python

[![Python](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
[![Tests](https://github.com/olegantonov/tse-eleicoes/actions/workflows/tests.yml/badge.svg)](https://github.com/olegantonov/tse-eleicoes/actions)

Cliente Python assíncrono para as APIs do [TSE (Tribunal Superior Eleitoral)](https://www.tse.jus.br/).

Integra dois serviços:
- **DivulgaCandContas REST** — dados em tempo real de candidatos, eleições e prestação de contas
- **CKAN Dados Abertos** — portal de datasets oficiais para download em massa

## 📋 Características

- ✅ Cliente assíncrono com `httpx`
- ✅ Tratamento robusto de erros com retry automático (backoff exponencial)
- ✅ Logging estruturado
- ✅ Type hints completos (Python 3.11+)
- ✅ Sem necessidade de autenticação
- ✅ 30+ métodos cobrindo eleições, candidatos, prestação de contas e dados abertos
- ✅ Constantes prontas: IDs de eleições e códigos de cargos

## 🚀 Instalação

```bash
# Clone o repositório
git clone https://github.com/olegantonov/tse-eleicoes.git
cd tse-eleicoes

# Instale as dependências
pip install -r requirements.txt

# Para desenvolvimento
pip install -r requirements-dev.txt
```

## 📖 Uso Básico

### Listar Eleições e Anos Eleitorais

```python
import asyncio
from tse_client import get_tse_client

async def main():
    client = get_tse_client()
    try:
        # Listar eleições ordinárias
        eleicoes = await client.get_eleicoes_ordinarias()

        # Anos com eleições disponíveis
        anos = await client.get_anos_eleitorais()
        print(f"Anos disponíveis: {anos}")
    finally:
        await client.close()

asyncio.run(main())
```

### Buscar Candidatos

```python
import asyncio
from tse_client import get_tse_client, ELEICOES_ORDINARIAS

async def main():
    client = get_tse_client()
    try:
        # Candidatos a prefeito em São Paulo nas Municipais 2024
        candidatos = await client.get_candidatos(
            ano=2024,
            municipio_cod="71072",   # código TSE de São Paulo
            eleicao_id=ELEICOES_ORDINARIAS["2024"],
            cargo_cod="11"           # 11 = Prefeito
        )

        for c in candidatos:
            print(f"{c.get('nomeUrna')} — {c.get('siglaPartido')}")

        # Filtrar por partido
        pt = await client.get_candidatos_por_partido(
            "PT", 2024, "71072", ELEICOES_ORDINARIAS["2024"], "11"
        )

        # Buscar por nome
        results = await client.buscar_candidato_por_nome(
            "Boulos", 2024, "71072", ELEICOES_ORDINARIAS["2024"], "11"
        )
    finally:
        await client.close()

asyncio.run(main())
```

### Detalhe de Candidato (bens, redes sociais, foto)

```python
async def main():
    client = get_tse_client()
    try:
        candidato = await client.get_candidato(
            ano=2024,
            municipio_cod="71072",
            eleicao_id="2045202024",
            candidato_id="280001636560"
        )

        # Bens declarados
        bens = await client.get_bens_candidato(2024, "71072", "2045202024", "280001636560")
        print(f"Total de bens: {len(bens)}")

        # Redes sociais
        redes = await client.get_redes_sociais_candidato(2024, "71072", "2045202024", "280001636560")

        # URL da foto
        foto = await client.get_foto_candidato_url(2024, "71072", "2045202024", "280001636560")
    finally:
        await client.close()
```

### Prestação de Contas

```python
async def main():
    client = get_tse_client()
    try:
        prestacao = await client.get_prestacao_contas(
            eleicao_id="2045202024",
            ano=2024,
            municipio_cod="71072",
            cargo_cod="11",
            candidato_id="280001636560"
        )

        # Top doadores
        ranking = await client.get_ranking_doadores(
            "2045202024", 2024, "71072", "11", "280001636560"
        )
        for doador in ranking[:5]:
            print(f"{doador.get('nome')}: R$ {doador.get('valor')}")
    finally:
        await client.close()
```

### Dados Abertos (CKAN)

```python
async def main():
    client = get_tse_client()
    try:
        # Listar todos os datasets
        datasets = await client.get_datasets()

        # Buscar datasets de candidatos
        candidatos_ds = await client.get_datasets_candidatos()

        # URL de download do ZIP de candidatos 2024
        url = await client.get_url_download_candidatos(2024)
        print(f"Download candidatos 2024: {url}")

        # URL de download dos resultados 2022
        url = await client.get_url_download_resultados(2022)
    finally:
        await client.close()
```

### Usando Constantes Prontas

```python
from tse_client import ELEICOES_ORDINARIAS, CARGOS, nome_cargo, nome_eleicao

# IDs de eleições conhecidos
print(ELEICOES_ORDINARIAS)
# {'2024': '2045202024', '2022': '2040602022', ...}

# Códigos de cargos
print(CARGOS)
# {'11': 'Prefeito', '13': 'Vereador', '5': 'Senador', ...}

# Lookup helpers
print(nome_cargo("11"))          # "Prefeito"
print(nome_eleicao("2045202024")) # "2024"
```

## 📚 Métodos Disponíveis

### Eleições
| Método | Descrição |
|--------|-----------|
| `get_eleicoes_ordinarias()` | Lista todas as eleições ordinárias |
| `get_anos_eleitorais()` | Lista anos eleitorais disponíveis |
| `get_cargos_municipio(eleicao_id, municipio_cod)` | Cargos disponíveis em um município |
| `get_eleicoes_suplementares_estados(ano)` | Estados com eleições suplementares |
| `get_eleicoes_suplementares(ano, uf)` | Eleições suplementares de um estado |

### Candidatos
| Método | Descrição |
|--------|-----------|
| `get_candidatos(ano, municipio_cod, eleicao_id, cargo_cod)` | Lista candidatos |
| `get_candidato(ano, municipio_cod, eleicao_id, candidato_id)` | Detalhe completo |
| `buscar_candidato_por_nome(nome, ...)` | Busca por nome (filtro local) |
| `get_bens_candidato(...)` | Bens declarados |
| `get_foto_candidato_url(...)` | URL da foto oficial |
| `get_redes_sociais_candidato(...)` | Redes sociais declaradas |
| `get_candidatos_eleitos(...)` | Filtra apenas eleitos |
| `get_candidatos_por_partido(sigla, ...)` | Filtra por partido |

### Prestação de Contas
| Método | Descrição |
|--------|-----------|
| `get_prestacao_contas(eleicao_id, ano, municipio_cod, cargo_cod, candidato_id)` | Prestação de contas completa |
| `get_ranking_doadores(...)` | Top doadores ordenados por valor |

### CKAN / Dados Abertos
| Método | Descrição |
|--------|-----------|
| `get_datasets()` | Lista todos os datasets |
| `buscar_datasets(termo)` | Busca por palavra-chave |
| `get_dataset(slug)` | Metadados e arquivos de um dataset |
| `get_datasets_candidatos()` | Datasets de candidatos |
| `get_datasets_resultados()` | Datasets de resultados eleitorais |
| `get_datasets_eleitorado()` | Datasets de eleitorado |
| `get_url_download_candidatos(ano)` | URL do ZIP de candidatos |
| `get_url_download_resultados(ano)` | URL do ZIP de resultados |

### Helpers e Constantes
| Símbolo | Descrição |
|---------|-----------|
| `ELEICOES_ORDINARIAS` | Dict `{ano: eleicao_id}` com IDs conhecidos |
| `CARGOS` | Dict `{codigo: nome}` com cargos eleitorais |
| `nome_cargo(codigo)` | Retorna nome do cargo pelo código |
| `nome_eleicao(eleicao_id)` | Retorna nome/ano da eleição pelo ID |

## 🔧 IDs de Eleições Conhecidos

| Ano | ID da Eleição | Tipo |
|-----|---------------|------|
| 2024 | `2045202024` | Municipais |
| 2022 | `2040602022` | Gerais (Federal) |
| 2020 | `2030402020` | Municipais |
| 2018 | `2022802018` | Gerais (Federal) |
| 2016 | `2` | Municipais |
| 2014 | `680` | Gerais |

## 🗳️ Códigos de Cargos

| Código | Cargo |
|--------|-------|
| 3 | Governador |
| 4 | Vice-Governador |
| 5 | Senador |
| 6 | Deputado Federal |
| 7 | Deputado Estadual |
| 11 | Prefeito |
| 12 | Vice-Prefeito |
| 13 | Vereador |

## 🧪 Testes

```bash
pip install -r requirements-dev.txt
pytest tests/ -v
```

## 📎 Referências

- [DivulgaCandContas REST API](https://divulgacandcontas.tse.jus.br/divulga/)
- [Portal de Dados Abertos do TSE](https://dadosabertos.tse.jus.br/)
- [CKAN API Docs](https://docs.ckan.org/en/2.10/api/)
- [TSE - Tribunal Superior Eleitoral](https://www.tse.jus.br/)

## 📄 Licença

MIT — veja [LICENSE](LICENSE) para detalhes.


path: pyproject.toml
content: [build-system]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.backends._legacy:_Backend"

[project]
name = "tse-eleicoes"
version = "1.0.0"
description = "Cliente assíncrono para as APIs do TSE (Tribunal Superior Eleitoral)"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.11"
authors = [{name = "olegantonov"}]
keywords = ["tse", "eleicoes", "candidatos", "api", "brasil", "dados-abertos"]

dependencies = [
    "httpx>=0.27.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=8.0",
    "pytest-asyncio>=0.23",
    "pytest-mock>=3.14",
    "respx>=0.21",
]

[tool.pytest.ini_options]
asyncio_mode = "auto"
markers = [
    "integration: marks tests as integration tests (deselect with '-m \"not integration\"')",
]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]

[tool.ruff]
target-version = "py311"
line-length = 120


path: references/ckan-api.md
content: # CKAN Dados Abertos TSE — Referência

Base URL: `https://dadosabertos.tse.jus.br/api/3/action`

Sem autenticação. API CKAN padrão.

## Endpoints

```
GET /package_list
→ Lista todos os slugs/IDs de datasets disponíveis

GET /package_search?q={termo}&rows=20
→ Busca datasets por palavra-chave

GET /package_show?id={slug}
→ Metadados completos + URLs dos arquivos de um dataset
```

## Exemplo de Resposta — package_show

```json
{
  "success": true,
  "result": {
    "id": "candidatos-2024",
    "title": "Candidatos 2024",
    "resources": [
      {
        "id": "abc123",
        "name": "candidatos_2024_SP.zip",
        "url": "https://cdn.tse.jus.br/candidatos_2024_SP.zip",
        "format": "ZIP"
      }
    ]
  }
}
```

## Datasets Relevantes (exemplos)

- `candidatos-2024` — Candidatos das Eleições 2024
- `resultados-2022` — Resultados Eleição 2022
- `eleitorado-2024` — Perfil do Eleitorado 2024
- [REDACTED_SECRET] — Contas de campanha 2024

## Links Úteis

- Portal: https://dadosabertos.tse.jus.br/
- CKAN Docs: https://docs.ckan.org/en/2.10/api/


path: references/divulgacandcontas-api.md
content: # DivulgaCandContas API — Referência

Base URL: `https://divulgacandcontas.tse.jus.br/divulga/rest/v1`

Sem autenticação. Não funciona direto no browser (sem CORS), mas funciona server-side com httpx.

## Endpoints

### Eleições

```
GET /eleicao/ordinarias
→ Lista eleições ordinárias disponíveis

GET /eleicao/anos-eleitorais
→ Array de anos com eleições

GET /eleicao/listar/municipios/{eleicaoId}/{municipioCod}/cargos
→ Cargos disponíveis em um município para uma eleição

GET /eleicao/estados/{ano}/ano
→ Estados com eleições suplementares no ano

GET /eleicao/suplementares/{ano}/{uf}
→ Eleições suplementares de um estado
```

### Candidatos

```
GET /candidatura/listar/{ano}/{municipioCod}/{eleicaoId}/{cargoCod}/candidatos
→ Lista candidatos

GET /candidatura/buscar/{ano}/{municipioCod}/{eleicaoId}/candidato/{candidatoId}
→ Detalhe completo do candidato (bens, redes sociais, foto, arquivos)
```

### Prestação de Contas

```
GET /prestador/consulta/{eleicaoId}/{ano}/{municipioCod}/{cargoCod}/90/90/{candidatoId}
→ Prestação de contas do candidato
```

## IDs de Eleições Confirmados (27/04/2026)

| Ano  | ID           | Tipo               |
|------|--------------|--------------------|
| 2024 | 2045202024   | Municipais         |
| 2022 | 2040602022   | Geral Federal      |
| 2020 | 2030402020   | Municipais         |
| 2018 | 2022802018   | Geral Federal      |
| 2016 | 2            | Municipais         |
| 2014 | 680          | Gerais             |

## Códigos de Cargos

| Código | Cargo           |
|--------|-----------------|
| 3      | Governador      |
| 4      | Vice-Governador |
| 5      | Senador         |
| 6      | Dep. Federal    |
| 7      | Dep. Estadual   |
| 9      | 1º Suplente     |
| 10     | 2º Suplente     |
| 11     | Prefeito        |
| 12     | Vice-Prefeito   |
| 13     | Vereador        |

## Exemplos de Municípios

| Município     | Código TSE |
|---------------|------------|
| São Paulo/SP  | 71072      |
| Rio de Janeiro/RJ | 60011  |
| Brasília/DF   | 97012      |
| Salvador/BA   | 38490      |
| Fortaleza/CE  | 16292      |


path: requirements-dev.txt
content: -r requirements.txt
pytest>=8.0.0
pytest-asyncio>=0.23.0
pytest-cov>=4.1.0
pytest-mock>=3.12.0
black>=24.0.0
mypy>=1.8.0
ruff>=0.2.0


path: requirements.txt
content: httpx>=0.27.0


path: scripts/example_usage.py
content: """
Exemplos de uso do cliente TSE.

Execute:
    python scripts/example_usage.py
"""
import asyncio
import logging

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")

from tse_client import (
    get_tse_client,
    ELEICOES_ORDINARIAS,
    CARGOS,
    nome_cargo,
    nome_eleicao,
)


async def main():
    client = get_tse_client()

    try:
        print("=" * 60)
        print("TSE Eleições — Exemplos de uso")
        print("=" * 60)

        # 1. Anos eleitorais
        print("\n📅 Anos eleitorais disponíveis:")
        anos = await client.get_anos_eleitorais()
        print(f"  {anos}")

        # 2. Eleições ordinárias
        print("\n🗳️  Eleições ordinárias (IDs conhecidos):")
        for ano, eleicao_id in ELEICOES_ORDINARIAS.items():
            print(f"  {ano} → {eleicao_id} ({nome_eleicao(eleicao_id)})")

        # 3. Cargos disponíveis
        print("\n🎖️  Cargos eleitorais:")
        for cod, nome in CARGOS.items():
            print(f"  {cod} = {nome}")

        # 4. Candidatos a prefeito em São Paulo (Municipais 2024)
        print("\n👤 Buscando candidatos a Prefeito em São Paulo (2024)...")
        try:
            candidatos = await client.get_candidatos(
                ano=2024,
                municipio_cod="71072",
                eleicao_id=ELEICOES_ORDINARIAS["2024"],
                cargo_cod="11",
            )
            print(f"  Total encontrado: {len(candidatos)}")
            for c in candidatos[:3]:
                print(f"  → {c.get('nomeUrna', c.get('nome', 'N/A'))}")
        except Exception as e:
            print(f"  ⚠️  {e}")

        # 5. Datasets CKAN
        print("\n📦 Datasets disponíveis no portal de dados abertos...")
        try:
            datasets = await client.get_datasets()
            print(f"  Total de datasets: {len(datasets)}")
            print(f"  Primeiros 5: {datasets[:5]}")
        except Exception as e:
            print(f"  ⚠️  {e}")

        # 6. Busca de datasets de candidatos
        print("\n🔍 Buscando datasets de candidatos...")
        try:
            ds_candidatos = await client.get_datasets_candidatos()
            print(f"  Encontrados: {len(ds_candidatos)} datasets")
            for ds in ds_candidatos[:2]:
                print(f"  → {ds.get('title', ds.get('name', 'N/A'))}")
        except Exception as e:
            print(f"  ⚠️  {e}")

        print("\n✅ Exemplos concluídos!")

    finally:
        await client.close()


if __name__ == "__main__":
    asyncio.run(main())


path: tests/test_tse_client.py
content: """Testes unitários para tse_client."""
import pytest
from unittest.mock import AsyncMock, Mock, patch, PropertyMock

from tse_client import (
    TSEClient,
    TSEAPIError,
    TSEConnectionError,
    TSETimeoutError,
    TSENotFoundError,
    get_tse_client,
    nome_cargo,
    nome_eleicao,
    ELEICOES_ORDINARIAS,
    CARGOS,
)


def _make_mock_client(responses):
    """Helper: cria mock httpx client com lista de respostas sequenciais."""
    mock_client = AsyncMock()
    type(mock_client).is_closed = PropertyMock(return_value=False)
    if isinstance(responses, list):
        mock_client.get.side_effect = responses
    else:
        mock_client.get.return_value = responses
    return mock_client


def _make_response(json_data, status_code=200):
    """Helper: cria mock de resposta HTTP."""
    resp = Mock()
    resp.json.return_value = json_data
    resp.raise_for_status = Mock()
    resp.status_code = status_code
    return resp


# ==================== TESTES DE CONSTANTES E HELPERS ====================

class TestConstantesHelpers:
    """Testes de constantes e funções auxiliares."""

    def test_eleicoes_ordinarias_dict(self):
        """Testa que o dicionário de eleições ordinárias contém entradas conhecidas."""
        assert "2024" in ELEICOES_ORDINARIAS
        assert ELEICOES_ORDINARIAS["2024"] == "2045202024"
        assert ELEICOES_ORDINARIAS["2022"] == "2040602022"

    def test_cargos_dict(self):
        """Testa que o dicionário de cargos contém entradas conhecidas."""
        assert "11" in CARGOS
        assert CARGOS["11"] == "Prefeito"
        assert CARGOS["13"] == "Vereador"

    def test_nome_cargo_conhecido(self):
        """Testa nome_cargo com código válido."""
        assert nome_cargo("11") == "Prefeito"
        assert nome_cargo(13) == "Vereador"
        assert nome_cargo("5") == "Senador"

    def test_nome_cargo_desconhecido(self):
        """Testa nome_cargo com código inválido."""
        assert nome_cargo("99") == "Cargo desconhecido"
        assert nome_cargo("0") == "Cargo desconhecido"

    def test_nome_eleicao_conhecida(self):
        """Testa nome_eleicao com ID válido."""
        resultado = nome_eleicao("2045202024")
        assert "2024" in resultado

    def test_nome_eleicao_desconhecida(self):
        """Testa nome_eleicao com ID desconhecido."""
        resultado = nome_eleicao("9999999999")
        assert "9999999999" in resultado


# ==================== TESTES DO CLIENTE ====================

@pytest.mark.asyncio
class TestTSEClient:
    """Testes do TSEClient."""

    async def test_client_initialization(self):
        """Testa inicialização do cliente."""
        client = TSEClient()
        assert client._divulga_client is None
        assert client._ckan_client is None
        assert client.timeout == 45.0

    async def test_custom_timeout(self):
        """Testa timeout customizado."""
        client = TSEClient(timeout=10.0)
        assert client.timeout == 10.0

    async def test_get_singleton(self):
        """Testa padrão singleton."""
        import tse_client as mod
        mod._client = None
        c1 = get_tse_client()
        c2 = get_tse_client()
        assert c1 is c2
        mod._client = None

    # ========== ELEIÇÕES ==========

    @patch("tse_client.httpx.AsyncClient")
    async def test_get_eleicoes_ordinarias_lista(self, mock_httpx):
        """Testa get_eleicoes_ordinarias com resposta em lista."""
        payload = [{"id": "2045202024", "nome": "Eleições Municipais 2024"}]
        mock_client = _make_mock_client(_make_response(payload))
        mock_httpx.return_value = mock_client

        client = TSEClient()
        result = await client.get_eleicoes_ordinarias()

        assert isinstance(result, list)
        assert result[0]["id"] == "2045202024"

    @patch("tse_client.httpx.AsyncClient")
    async def test_get_eleicoes_ordinarias_dict(self, mock_httpx):
        """Testa get_eleicoes_ordinarias com resposta em dicionário."""
        payload = {"eleicoes": [{"id": "2045202024"}]}
        mock_client = _make_mock_client(_make_response(payload))
        mock_httpx.return_value = mock_client

        client = TSEClient()
        result = await client.get_eleicoes_ordinarias()

        assert isinstance(result, list)
        assert result[0]["id"] == "2045202024"

    @patch("tse_client.httpx.AsyncClient")
    async def test_get_anos_eleitorais(self, mock_httpx):
        """Testa get_anos_eleitorais."""
        payload = [2024, 2022, 2020, 2018]
        mock_client = _make_mock_client(_make_response(payload))
        mock_httpx.return_value = mock_client

        client = TSEClient()
        result = await client.get_anos_eleitorais()

        assert isinstance(result, list)
        assert 2024 in result

    @patch("tse_client.httpx.AsyncClient")
    async def test_get_cargos_municipio(self, mock_httpx):
        """Testa get_cargos_municipio."""
        payload = {"cargos": [{"codigo": "11", "nome": "Prefeito"}]}
        mock_client = _make_mock_client(_make_response(payload))
        mock_httpx.return_value = mock_client

        client = TSEClient()
        result = await client.get_cargos_municipio("2045202024", "71072")

        assert isinstance(result, list)
        assert result[0]["codigo"] == "11"

    @patch("tse_client.httpx.AsyncClient")
    async def test_get_eleicoes_suplementares_estados(self, mock_httpx):
        """Testa get_eleicoes_suplementares_estados."""
        payload = [{"sigla": "SP"}, {"sigla": "MG"}]
        mock_client = _make_mock_client(_make_response(payload))
        mock_httpx.return_value = mock_client

        client = TSEClient()
        result = await client.get_eleicoes_suplementares_estados(2022)

        assert isinstance(result, list)

    @patch("tse_client.httpx.AsyncClient")
    async def test_get_eleicoes_suplementares(self, mock_httpx):
        """Testa get_eleicoes_suplementares."""
        payload = {"eleicoes": [{"id": "123", "nome": "Suplementar SP"}]}
        mock_client = _make_mock_client(_make_response(payload))
        mock_httpx.return_value = mock_client

        client = TSEClient()
        result = await client.get_eleicoes_suplementares(2022, "SP")

        assert isinstance(result, list)

    # ========== CANDIDATOS ==========

    @patch("tse_client.httpx.AsyncClient")
    async def test_get_candidatos_success(self, mock_httpx):
        """Testa listagem de candidatos com sucesso."""
        payload = {"candidatos": [
            {"id": "123", "nomeUrna": "LULA", "siglaPartido": "PT"},
            {"id": "456", "nomeUrna": "BOLSONARO", "siglaPartido": "PL"},
        ]}
        mock_client = _make_mock_client(_make_response(payload))
        mock_httpx.return_value = mock_client

        client = TSEClient()
        result = await client.get_candidatos(2022, "71072", "2040602022", "1")

        assert isinstance(result, list)
        assert len(result) == 2

    @patch("tse_client.httpx.AsyncClient")
    async def test_get_candidato_detalhe(self, mock_httpx):
        """Testa detalhe completo de candidato."""
        payload = {
            "id": "123",
            "nomeUrna": "LULA",
            "bens": [{"descricao": "Imóvel", "valor": "500000"}],
            "sites": [{"url": "https://lula.com.br", "tipo": "site"}],
        }
        mock_client = _make_mock_client(_make_response(payload))
        mock_httpx.return_value = mock_client

        client = TSEClient()
        result = await client.get_candidato(2022, "71072", "2040602022", "123")

        assert result["nomeUrna"] == "LULA"

    @patch("tse_client.httpx.AsyncClient")
    async def test_buscar_candidato_por_nome(self, mock_httpx):
        """Testa busca de candidato por nome."""
        payload = {"candidatos": [
            {"id": "1", "nomeUrna": "JOÃO SILVA", "nomeCompleto": "João da Silva"},
            {"id": "2", "nomeUrna": "MARIA SOUZA", "nomeCompleto": "Maria de Souza"},
        ]}
        mock_client = _make_mock_client(_make_response(payload))
        mock_httpx.return_value = mock_client

        client = TSEClient()
        result = await client.buscar_candidato_por_nome("joão", 2024, "71072", "2045202024", "11")

        assert len(result) == 1
        assert "JOÃO" in result[0]["nomeUrna"]

    @patch("tse_client.httpx.AsyncClient")
    async def test_buscar_candidato_nao_encontrado(self, mock_httpx):
        """Testa busca que não encontra candidato."""
        payload = {"candidatos": [{"id": "1", "nomeUrna": "JOÃO"}]}
        mock_client = _make_mock_client(_make_response(payload))
        mock_httpx.return_value = mock_client

        client = TSEClient()
        result = await client.buscar_candidato_por_nome("ZZZNAOEXISTE", 2024, "71072", "2045202024", "11")

        assert len(result) == 0

    @patch("tse_client.httpx.AsyncClient")
    async def test_get_bens_candidato(self, mock_httpx):
        """Testa extração de bens do candidato."""
        payload = {
            "id": "123",
            "bens": [
                {"descricao": "Veículo", "valor": "80000"},
                {"descricao": "Imóvel", "valor": "300000"},
            ],
        }
        mock_client = _make_mock_client(_make_response(payload))
        mock_httpx.return_value = mock_client

        client = TSEClient()
        result = await client.get_bens_candidato(2024, "71072", "2045202024", "123")

        assert isinstance(result, list)
        assert len(result) == 2

    @patch("tse_client.httpx.AsyncClient")
    async def test_get_foto_candidato_url(self, mock_httpx):
        """Testa extração de URL da foto do candidato."""
        payload = {
            "id": "123",
            "fotoUrl": "https://example.com/foto.jpg",
        }
        mock_client = _make_mock_client(_make_response(payload))
        mock_httpx.return_value = mock_client

        client = TSEClient()
        result = await client.get_foto_candidato_url(2024, "71072", "2045202024", "123")

        assert result == "https://example.com/foto.jpg"

    @patch("tse_client.httpx.AsyncClient")
    async def test_get_redes_sociais_candidato(self, mock_httpx):
        """Testa extração de redes sociais do candidato."""
        payload = {
            "id": "123",
            "sites": [
                {"url": "https://instagram.com/candidato", "tipo": "instagram"},
            ],
        }
        mock_client = _make_mock_client(_make_response(payload))
        mock_httpx.return_value = mock_client

        client = TSEClient()
        result = await client.get_redes_sociais_candidato(2024, "71072", "2045202024", "123")

        assert isinstance(result, list)
        assert len(result) == 1

    @patch("tse_client.httpx.AsyncClient")
    async def test_get_candidatos_eleitos(self, mock_httpx):
        """Testa filtro de candidatos eleitos."""
        payload = {"candidatos": [
            {"id": "1", "nomeUrna": "ELEITO", "situacao": "eleito"},
            {"id": "2", "nomeUrna": "NAO ELEITO", "situacao": "não eleito"},
        ]}
        mock_client = _make_mock_client(_make_response(payload))
        mock_httpx.return_value = mock_client

        client = TSEClient()
        result = await client.get_candidatos_eleitos(2024, "71072", "2045202024", "11")

        assert len(result) == 1
        assert result[0]["nomeUrna"] == "ELEITO"

    @patch("tse_client.httpx.AsyncClient")
    async def test_get_candidatos_por_partido(self, mock_httpx):
        """Testa filtro de candidatos por partido."""
        payload = {"candidatos": [
            {"id": "1", "nomeUrna": "CANDIDATO PT", "siglaPartido": "PT"},
            {"id": "2", "nomeUrna": "CANDIDATO PL", "siglaPartido": "PL"},
        ]}
        mock_client = _make_mock_client(_make_response(payload))
        mock_httpx.return_value = mock_client

        client = TSEClient()
        result = await client.get_candidatos_por_partido("PT", 2022, "71072", "2040602022", "1")

        assert len(result) == 1
        assert result[0]["siglaPartido"] == "PT"

    # ========== PRESTAÇÃO DE CONTAS ==========

    @patch("tse_client.httpx.AsyncClient")
    async def test_get_prestacao_contas(self, mock_httpx):
        """Testa prestação de contas."""
        payload = {
            "totalReceitas": 100000,
            "totalDespesas": 95000,
            "doadores": [{"nome": "Fulano", "valor": "10000"}],
        }
        mock_client = _make_mock_client(_make_response(payload))
        mock_httpx.return_value = mock_client

        client = TSEClient()
        result = await client.get_prestacao_contas("2045202024", 2024, "71072", "11", "123")

        assert result["totalReceitas"] == 100000

    @patch("tse_client.httpx.AsyncClient")
    async def test_get_ranking_doadores(self, mock_httpx):
        """Testa ranking de doadores (ordenado por valor desc)."""
        payload = {
            "doadores": [
                {"nome": "B", "valor": "5000"},
                {"nome": "A", "valor": "20000"},
                {"nome": "C", "valor": "1000"},
            ],
        }
        mock_client = _make_mock_client(_make_response(payload))
        mock_httpx.return_value = mock_client

        client = TSEClient()
        result = await client.get_ranking_doadores("2045202024", 2024, "71072", "11", "123")

        assert isinstance(result, list)
        assert float(result[0]["valor"]) >= float(result[1]["valor"])

    # ========== CKAN / DADOS ABERTOS ==========

    @patch("tse_client.httpx.AsyncClient")
    async def test_get_datasets(self, mock_httpx):
        """Testa listagem de datasets CKAN."""
        payload = {"result": ["candidatos-2024", "resultados-2022", "eleitorado-2024"]}
        mock_client = _make_mock_client(_make_response(payload))
        mock_httpx.return_value = mock_client

        client = TSEClient()
        result = await client.get_datasets()

        assert isinstance(result, list)
        assert "candidatos-2024" in result

    @patch("tse_client.httpx.AsyncClient")
    async def test_buscar_datasets(self, mock_httpx):
        """Testa busca de datasets por termo."""
        payload = {
            "result": {
                "results": [
                    {"id": "candidatos-2024", "title": "Candidatos 2024"},
                ]
            }
        }
        mock_client = _make_mock_client(_make_response(payload))
        mock_httpx.return_value = mock_client

        client = TSEClient()
        result = await client.buscar_datasets("candidatos")

        assert isinstance(result, list)
        assert result[0]["id"] == "candidatos-2024"

    @patch("tse_client.httpx.AsyncClient")
    async def test_get_dataset(self, mock_httpx):
        """Testa busca de dataset por slug."""
        payload = {
            "result": {
                "id": "candidatos-2024",
                "title": "Candidatos 2024",
                "resources": [{"url": "https://example.com/file.zip"}],
            }
        }
        mock_client = _make_mock_client(_make_response(payload))
        mock_httpx.return_value = mock_client

        client = TSEClient()
        result = await client.get_dataset("candidatos-2024")

        assert result["id"] == "candidatos-2024"

    @patch("tse_client.httpx.AsyncClient")
    async def test_get_url_download_candidatos(self, mock_httpx):
        """Testa URL de download de candidatos."""
        payload = {
            "result": {
                "results": [
                    {
                        "id": "candidatos-2024",
                        "resources": [
                            {"url": "https://cdn.tse.jus.br/candidatos_2024.zip"},
                        ],
                    }
                ]
            }
        }
        mock_client = _make_mock_client(_make_response(payload))
        mock_httpx.return_value = mock_client

        client = TSEClient()
        result = await client.get_url_download_candidatos(2024)

        assert result is not None
        assert "2024" in result
        assert result.endswith(".zip")

    @patch("tse_client.httpx.AsyncClient")
    async def test_get_url_download_candidatos_nao_encontrado(self, mock_httpx):
        """Testa URL de download quando não encontrado."""
        payload = {"result": {"results": []}}
        mock_client = _make_mock_client(_make_response(payload))
        mock_httpx.return_value = mock_client

        client = TSEClient()
        result = await client.get_url_download_candidatos(2024)

        assert result is None

    # ========== TRATAMENTO DE ERROS ==========

    @patch("tse_client.httpx.AsyncClient")
    async def test_get_timeout_retry(self, mock_httpx):
        """Testa retry após timeout."""
        import httpx
        success = _make_response({"ok": True})
        mock_client = _make_mock_client([
            httpx.TimeoutException("Timeout"),
            success,
        ])
        mock_httpx.return_value = mock_client

        client = TSEClient()
        result = await client._get_divulga("/test")

        assert result == {"ok": True}
        assert mock_client.get.call_count == 2

    @patch("tse_client.httpx.AsyncClient")
    async def test_get_timeout_exhausts_retries(self, mock_httpx):
        """Testa que timeout esgota tentativas e lança TSETimeoutError."""
        import httpx
        mock_client = AsyncMock()
        type(mock_client).is_closed = PropertyMock(return_value=False)
        mock_client.get.side_effect = httpx.TimeoutException("Timeout")
        mock_httpx.return_value = mock_client

        client = TSEClient()
        with pytest.raises(TSETimeoutError):
            await client._get("/test", retries=2)

        assert mock_client.get.call_count == 2

    @patch("tse_client.httpx.AsyncClient")
    async def test_get_404_raises_not_found(self, mock_httpx):
        """Testa que 404 lança TSENotFoundError."""
        import httpx
        mock_response = Mock()
        mock_response.status_code = 404
        mock_client = AsyncMock()
        type(mock_client).is_closed = PropertyMock(return_value=False)
        mock_client.get.side_effect = httpx.HTTPStatusError(
            "Not Found", request=Mock(), response=mock_response
        )
        mock_httpx.return_value = mock_client

        client = TSEClient()
        with pytest.raises(TSENotFoundError):
            await client._get("/test")

    @patch("tse_client.httpx.AsyncClient")
    async def test_get_connection_error(self, mock_httpx):
        """Testa que erro de conexão lança TSEConnectionError."""
        import httpx
        mock_client = AsyncMock()
        type(mock_client).is_closed = PropertyMock(return_value=False)
        mock_client.get.side_effect = httpx.ConnectError("Connection refused")
        mock_httpx.return_value = mock_client

        client = TSEClient()
        with pytest.raises(TSEConnectionError):
            await client._get("/test", retries=1)

    @patch("tse_client.httpx.AsyncClient")
    async def test_get_500_retry(self, mock_httpx):
        """Testa retry em erro 500."""
        import httpx
        mock_response_500 = Mock()
        mock_response_500.status_code = 500
        success = _make_response({"data": "ok"})
        mock_client = AsyncMock()
        type(mock_client).is_closed = PropertyMock(return_value=False)
        mock_client.get.side_effect = [
            httpx.HTTPStatusError("Server Error", request=Mock(), response=mock_response_500),
            success,
        ]
        mock_httpx.return_value = mock_client

        client = TSEClient()
        result = await client._get("/test")

        assert result == {"data": "ok"}


path: tse_client.py
content: """
Cliente async para APIs do TSE (Tribunal Superior Eleitoral).

Fontes:
    - DivulgaCandContas REST: https://divulgacandcontas.tse.jus.br/divulga/rest/v1
    - CKAN Dados Abertos: https://dadosabertos.tse.jus.br/api/3/action

Uso:
    from tse_client import get_tse_client

    async def main():
        client = get_tse_client()
        try:
            # Listar eleições ordinárias
            eleicoes = await client.get_eleicoes_ordinarias()

            # Buscar candidatos
            candidatos = await client.get_candidatos(
                ano=2024,
                municipio_cod="71072",
                eleicao_id="2045202024",
                cargo_cod="11"
            )

            # Dados abertos
            datasets = await client.get_datasets_candidatos()
        finally:
            await client.close()
"""
import asyncio
import logging
from typing import Any

import httpx


# Configurar logger
logger = logging.getLogger(__name__)


# ==================== EXCEÇÕES ====================

class TSEAPIError(Exception):
    """Erro base para erros da API do TSE."""
    pass


class TSEConnectionError(TSEAPIError):
    """Erro de conexão com a API."""
    pass


class TSETimeoutError(TSEAPIError):
    """Timeout ao conectar com a API."""
    pass


class TSENotFoundError(TSEAPIError):
    """Recurso não encontrado."""
    pass


class TSEValidationError(TSEAPIError):
    """Erro de validação de parâmetros."""
    pass


# ==================== CONSTANTES ====================

BASE_URL_DIVULGA = "https://divulgacandcontas.tse.jus.br/divulga/rest/v1"
BASE_URL_CKAN = "https://dadosabertos.tse.jus.br/api/3/action"

# Timeout padrão para requisições (45s)
DEFAULT_TIMEOUT = 45.0

# IDs de eleições ordinárias conhecidos
ELEICOES_ORDINARIAS: dict[str, str] = {
    "2024": "2045202024",
    "2022": "2040602022",
    "2020": "2030402020",
    "2018": "2022802018",
    "2016": "2",
    "2014": "680",
}

# Códigos de cargos eleitorais
CARGOS: dict[str, str] = {
    "3": "Governador",
    "4": "Vice-Governador",
    "5": "Senador",
    "6": "Deputado Federal",
    "7": "Deputado Estadual",
    "9": "1º Suplente",
    "10": "2º Suplente",
    "11": "Prefeito",
    "12": "Vice-Prefeito",
    "13": "Vereador",
}


def nome_cargo(codigo: str | int) -> str:
    """
    Retorna o nome do cargo pelo código.

    Args:
        codigo: Código do cargo (ex: '11' ou 11)

    Returns:
        Nome do cargo ou 'Cargo desconhecido' se não encontrado.
    """
    return CARGOS.get(str(codigo), "Cargo desconhecido")


def nome_eleicao(eleicao_id: str | int) -> str:
    """
    Retorna o nome/ano da eleição pelo ID.

    Args:
        eleicao_id: ID da eleição (ex: '2045202024')

    Returns:
        String descritiva da eleição ou o próprio ID se não encontrado.
    """
    inv = {v: k for k, v in ELEICOES_ORDINARIAS.items()}
    return inv.get(str(eleicao_id), f"Eleição {eleicao_id}")


# ==================== CLIENTE ====================

class TSEClient:
    """Cliente assíncrono para as APIs do TSE."""

    def __init__(self, timeout: float = DEFAULT_TIMEOUT):
        self._divulga_client: httpx.AsyncClient | None = None
        self._ckan_client: httpx.AsyncClient | None = None
        self.timeout = timeout

    async def _get_divulga_client(self) -> httpx.AsyncClient:
        """Retorna (ou cria) o cliente HTTP para a API DivulgaCandContas."""
        if self._divulga_client is None or self._divulga_client.is_closed:
            self._divulga_client = httpx.AsyncClient(
                base_url=BASE_URL_DIVULGA,
                timeout=httpx.Timeout(self.timeout),
                follow_redirects=True,
            )
        return self._divulga_client

    async def _get_ckan_client(self) -> httpx.AsyncClient:
        """Retorna (ou cria) o cliente HTTP para a API CKAN."""
        if self._ckan_client is None or self._ckan_client.is_closed:
            self._ckan_client = httpx.AsyncClient(
                base_url=BASE_URL_CKAN,
                timeout=httpx.Timeout(self.timeout),
                follow_redirects=True,
            )
        return self._ckan_client

    async def close(self):
        """Fecha os clientes HTTP."""
        if self._divulga_client and not self._divulga_client.is_closed:
            await self._divulga_client.aclose()
        if self._ckan_client and not self._ckan_client.is_closed:
            await self._ckan_client.aclose()

    async def _get(
        self,
        path: str,
        params: dict | None = None,
        retries: int = 3,
        _client_type: str = "divulga",
    ) -> dict:
        """
        Executa GET com retry e backoff exponencial.

        Args:
            path: Caminho do endpoint
            params: Parâmetros de query
            retries: Número de tentativas
            _client_type: 'divulga' ou 'ckan'

        Raises:
            TSETimeoutError: Timeout na requisição
            TSENotFoundError: Recurso não encontrado (404)
            TSEConnectionError: Erro de conexão
            TSEAPIError: Outros erros da API
        """
        if _client_type == "ckan":
            client = await self._get_ckan_client()
        else:
            client = await self._get_divulga_client()

        last_error: Exception | None = None

        for attempt in range(retries):
            try:
                logger.debug(f"GET {path} — tentativa {attempt + 1}/{retries}")
                response = await client.get(path, params=params)
                response.raise_for_status()
                return response.json()

            except httpx.TimeoutException as e:
                last_error = TSETimeoutError(f"Timeout ao acessar {path}: {e}")
                logger.warning(f"Timeout na tentativa {attempt + 1}/{retries}")
                if attempt < retries - 1:
                    await asyncio.sleep(2 ** attempt)

            except httpx.HTTPStatusError as e:
                if e.response.status_code == 404:
                    raise TSENotFoundError(f"Recurso não encontrado: {path}")
                last_error = TSEAPIError(f"Erro HTTP {e.response.status_code}: {e}")
                if e.response.status_code >= 500:
                    logger.warning(f"Erro {e.response.status_code} na tentativa {attempt + 1}/{retries}")
                    if attempt < retries - 1:
                        await asyncio.sleep(2 ** attempt)
                        continue
                logger.error(f"Erro HTTP: {e}")
                break

            except httpx.ConnectError as e:
                last_error = TSEConnectionError(f"Erro de conexão: {e}")
                logger.warning(f"Erro de conexão na tentativa {attempt + 1}/{retries}")
                if attempt < retries - 1:
                    await asyncio.sleep(2 ** attempt)

            except Exception as e:
                last_error = TSEAPIError(f"Erro inesperado: {e}")
                logger.error(f"Erro inesperado: {e}")
                break

        if last_error:
            raise last_error

        raise TSEAPIError("Falha após todas as tentativas")

    async def _get_divulga(self, path: str, params: dict | None = None, retries: int = 3) -> Any:
        """Atalho para chamadas à API DivulgaCandContas."""
        return await self._get(path, params=params, retries=retries, _client_type="divulga")

    async def _get_ckan(self, path: str, params: dict | None = None, retries: int = 3) -> dict:
        """Atalho para chamadas à API CKAN."""
        return await self._get(path, params=params, retries=retries, _client_type="ckan")

    # ==================== ELEIÇÕES ====================

    async def get_eleicoes_ordinarias(self) -> list[dict]:
        """
        Lista todas as eleições ordinárias disponíveis.

        Returns:
            Lista de dicionários com dados das eleições ordinárias.
        """
        data = await self._get_divulga("/eleicao/ordinarias")
        return data if isinstance(data, list) else data.get("eleicoes", data.get("results", []))

    async def get_anos_eleitorais(self) -> list[int]:
        """
        Lista os anos eleitorais disponíveis na API.

        Returns:
            Lista de anos (inteiros).
        """
        data = await self._get_divulga("/eleicao/anos-eleitorais")
        if isinstance(data, list):
            return data
        return data.get("anos", [])

    async def get_cargos_municipio(self, eleicao_id: str, municipio_cod: str) -> list[dict]:
        """
        Lista os cargos disponíveis em um município para uma eleição.

        Args:
            eleicao_id: ID da eleição (ex: '2045202024')
            municipio_cod: Código TSE do município (ex: '71072' para SP)

        Returns:
            Lista de dicionários com dados dos cargos.
        """
        data = await self._get_divulga(f"/eleicao/listar/municipios/{eleicao_id}/{municipio_cod}/cargos")
        if isinstance(data, list):
            return data
        return data.get("cargos", [])

    async def get_eleicoes_suplementares_estados(self, ano: int) -> list[dict]:
        """
        Lista os estados que tiveram eleições suplementares em um ano.

        Args:
            ano: Ano eleitoral (ex: 2022)

        Returns:
            Lista de dicionários com dados dos estados.
        """
        data = await self._get_divulga(f"/eleicao/estados/{ano}/ano")
        if isinstance(data, list):
            return data
        return data.get("estados", [])

    async def get_eleicoes_suplementares(self, ano: int, uf: str) -> list[dict]:
        """
        Lista as eleições suplementares de um estado em um ano.

        Args:
            ano: Ano eleitoral (ex: 2022)
            uf: Sigla do estado (ex: 'SP', 'MG')

        Returns:
            Lista de dicionários com dados das eleições suplementares.
        """
        data = await self._get_divulga(f"/eleicao/suplementares/{ano}/{uf.upper()}")
        if isinstance(data, list):
            return data
        return data.get("eleicoes", [])

    # ==================== CANDIDATOS ====================

    async def get_candidatos(
        self,
        ano: int,
        municipio_cod: str,
        eleicao_id: str,
        cargo_cod: str,
    ) -> list[dict]:
        """
        Lista os candidatos de um cargo em um município/eleição.

        Args:
            ano: Ano eleitoral
            municipio_cod: Código TSE do município
            eleicao_id: ID da eleição
            cargo_cod: Código do cargo (ex: '11' para Prefeito)

        Returns:
            Lista de dicionários com dados dos candidatos.
        """
        data = await self._get_divulga(
            f"/candidatura/listar/{ano}/{municipio_cod}/{eleicao_id}/{cargo_cod}/candidatos"
        )
        if isinstance(data, list):
            return data
        return data.get("candidatos", [])

    async def get_candidato(
        self,
        ano: int,
        municipio_cod: str,
        eleicao_id: str,
        candidato_id: str,
    ) -> dict:
        """
        Retorna os dados completos de um candidato (bens, redes sociais, foto, arquivos).

        Args:
            ano: Ano eleitoral
            municipio_cod: Código TSE do município
            eleicao_id: ID da eleição
            candidato_id: ID do candidato

        Returns:
            Dicionário com todos os dados do candidato.
        """
        return await self._get_divulga(
            f"/candidatura/buscar/{ano}/{municipio_cod}/{eleicao_id}/candidato/{candidato_id}"
        )

    async def buscar_candidato_por_nome(
        self,
        nome: str,
        ano: int,
        municipio_cod: str,
        eleicao_id: str,
        cargo_cod: str,
    ) -> list[dict]:
        """
        Busca candidatos por nome (filtro local sobre a listagem completa).

        Args:
            nome: Nome ou parte do nome do candidato
            ano: Ano eleitoral
            municipio_cod: Código TSE do município
            eleicao_id: ID da eleição
            cargo_cod: Código do cargo

        Returns:
            Lista de candidatos cujo nome contém o termo buscado.
        """
        todos = await self.get_candidatos(ano, municipio_cod, eleicao_id, cargo_cod)
        nome_lower = nome.lower()
        return [
            c for c in todos
            if nome_lower in c.get("nomeUrna", "").lower()
            or nome_lower in c.get("nomeCompleto", "").lower()
            or nome_lower in c.get("nome", "").lower()
        ]

    async def get_bens_candidato(
        self,
        ano: int,
        municipio_cod: str,
        eleicao_id: str,
        candidato_id: str,
    ) -> list[dict]:
        """
        Retorna apenas os bens declarados de um candidato.

        Args:
            ano: Ano eleitoral
            municipio_cod: Código TSE do município
            eleicao_id: ID da eleição
            candidato_id: ID do candidato

        Returns:
            Lista de bens declarados.
        """
        data = await self.get_candidato(ano, municipio_cod, eleicao_id, candidato_id)
        return data.get("bens", [])

    async def get_foto_candidato_url(
        self,
        ano: int,
        municipio_cod: str,
        eleicao_id: str,
        candidato_id: str,
    ) -> str | None:
        """
        Retorna a URL da foto de um candidato.

        Args:
            ano: Ano eleitoral
            municipio_cod: Código TSE do município
            eleicao_id: ID da eleição
            candidato_id: ID do candidato

        Returns:
            URL da foto ou None se não disponível.
        """
        data = await self.get_candidato(ano, municipio_cod, eleicao_id, candidato_id)
        return data.get("fotoUrl") or data.get("foto") or data.get("fotoPath")

    async def get_redes_sociais_candidato(
        self,
        ano: int,
        municipio_cod: str,
        eleicao_id: str,
        candidato_id: str,
    ) -> list[dict]:
        """
        Retorna as redes sociais declaradas de um candidato.

        Args:
            ano: Ano eleitoral
            municipio_cod: Código TSE do município
            eleicao_id: ID da eleição
            candidato_id: ID do candidato

        Returns:
            Lista de redes sociais.
        """
        data = await self.get_candidato(ano, municipio_cod, eleicao_id, candidato_id)
        return data.get("sites", data.get("redesSociais", []))

    async def get_candidatos_eleitos(
        self,
        ano: int,
        municipio_cod: str,
        eleicao_id: str,
        cargo_cod: str,
    ) -> list[dict]:
        """
        Retorna apenas os candidatos eleitos em um cargo/município.

        Args:
            ano: Ano eleitoral
            municipio_cod: Código TSE do município
            eleicao_id: ID da eleição
            cargo_cod: Código do cargo

        Returns:
            Lista de candidatos com situação de eleito.
        """
        todos = await self.get_candidatos(ano, municipio_cod, eleicao_id, cargo_cod)

        def _is_eleito(candidato: dict) -> bool:
            campos = [
                str(candidato.get("situacao", "")).lower(),
                str(candidato.get("descricaoSituacao", "")).lower(),
                str(candidato.get("situacaoTotalizacao", "")).lower(),
            ]
            for campo in campos:
                # Aceita "eleito" ou "eleita", mas rejeita "não eleito"
                if campo in ("eleito", "eleita", "elected"):
                    return True
                if campo.startswith("eleito") or campo.startswith("eleita"):
                    return True
            return False

        return [c for c in todos if _is_eleito(c)]

    async def get_candidatos_por_partido(
        self,
        sigla_partido: str,
        ano: int,
        municipio_cod: str,
        eleicao_id: str,
        cargo_cod: str,
    ) -> list[dict]:
        """
        Filtra candidatos pelo partido.

        Args:
            sigla_partido: Sigla do partido (ex: 'PT', 'PL', 'MDB')
            ano: Ano eleitoral
            municipio_cod: Código TSE do município
            eleicao_id: ID da eleição
            cargo_cod: Código do cargo

        Returns:
            Lista de candidatos do partido.
        """
        todos = await self.get_candidatos(ano, municipio_cod, eleicao_id, cargo_cod)
        sigla_upper = sigla_partido.upper()
        return [
            c for c in todos
            if sigla_upper in str(c.get("partido", {}).get("sigla", "")).upper()
            or sigla_upper in str(c.get("siglaPartido", "")).upper()
        ]

    # ==================== PRESTAÇÃO DE CONTAS ====================

    async def get_prestacao_contas(
        self,
        eleicao_id: str,
        ano: int,
        municipio_cod: str,
        cargo_cod: str,
        candidato_id: str,
    ) -> dict:
        """
        Retorna a prestação de contas de um candidato.

        Args:
            eleicao_id: ID da eleição
            ano: Ano eleitoral
            municipio_cod: Código TSE do município
            cargo_cod: Código do cargo
            candidato_id: ID do candidato

        Returns:
            Dicionário com dados da prestação de contas.
        """
        return await self._get_divulga(
            f"/prestador/consulta/{eleicao_id}/{ano}/{municipio_cod}/{cargo_cod}/90/90/{candidato_id}"
        )

    async def get_ranking_doadores(
        self,
        eleicao_id: str,
        ano: int,
        municipio_cod: str,
        cargo_cod: str,
        candidato_id: str,
    ) -> list[dict]:
        """
        Retorna o ranking de doadores de um candidato.

        Args:
            eleicao_id: ID da eleição
            ano: Ano eleitoral
            municipio_cod: Código TSE do município
            cargo_cod: Código do cargo
            candidato_id: ID do candidato

        Returns:
            Lista de doadores ordenada por valor.
        """
        data = await self.get_prestacao_contas(eleicao_id, ano, municipio_cod, cargo_cod, candidato_id)
        doadores = data.get("doadores", data.get("receitas", data.get("doadoresRanking", [])))
        if isinstance(doadores, list):
            return sorted(doadores, key=lambda x: float(x.get("valor", 0)), reverse=True)
        return []

    # ==================== CKAN / DADOS ABERTOS ====================

    async def get_datasets(self) -> list[str]:
        """
        Lista todos os datasets disponíveis no portal de dados abertos do TSE.

        Returns:
            Lista de slugs/IDs dos datasets.
        """
        data = await self._get_ckan("/package_list")
        return data.get("result", [])

    async def buscar_datasets(self, termo: str) -> list[dict]:
        """
        Busca datasets por termo/palavra-chave.

        Args:
            termo: Termo de busca (ex: 'candidatos', 'resultados')

        Returns:
            Lista de dicionários com metadados dos datasets encontrados.
        """
        data = await self._get_ckan("/package_search", params={"q": termo, "rows": 20})
        return data.get("result", {}).get("results", [])

    async def get_dataset(self, slug: str) -> dict:
        """
        Retorna os metadados e URLs de arquivos de um dataset.

        Args:
            slug: Identificador do dataset (ex: 'candidatos-2024')

        Returns:
            Dicionário com metadados e recursos do dataset.
        """
        data = await self._get_ckan("/package_show", params={"id": slug})
        return data.get("result", {})

    async def get_datasets_candidatos(self) -> list[dict]:
        """
        Retorna apenas os datasets relacionados a candidatos.

        Returns:
            Lista de datasets de candidatos.
        """
        return await self.buscar_datasets("candidatos")

    async def get_datasets_resultados(self) -> list[dict]:
        """
        Retorna apenas os datasets de resultados eleitorais.

        Returns:
            Lista de datasets de resultados.
        """
        return await self.buscar_datasets("resultados")

    async def get_datasets_eleitorado(self) -> list[dict]:
        """
        Retorna apenas os datasets de eleitorado.

        Returns:
            Lista de datasets de eleitorado.
        """
        return await self.buscar_datasets("eleitorado")

    async def get_url_download_candidatos(self, ano: int) -> str | None:
        """
        Retorna a URL de download do arquivo ZIP de candidatos de um ano.

        Args:
            ano: Ano eleitoral (ex: 2024)

        Returns:
            URL do arquivo ZIP ou None se não encontrado.
        """
        resultados = await self.buscar_datasets(f"candidatos {ano}")
        for dataset in resultados:
            recursos = dataset.get("resources", [])
            for recurso in recursos:
                url = recurso.get("url", "")
                if str(ano) in url and url.endswith(".zip"):
                    return url
        return None

    async def get_url_download_resultados(self, ano: int) -> str | None:
        """
        Retorna a URL de download do arquivo ZIP de resultados de um ano.

        Args:
            ano: Ano eleitoral (ex: 2022)

        Returns:
            URL do arquivo ZIP ou None se não encontrado.
        """
        resultados = await self.buscar_datasets(f"resultados {ano}")
        for dataset in resultados:
            recursos = dataset.get("resources", [])
            for recurso in recursos:
                url = recurso.get("url", "")
                if str(ano) in url and url.endswith(".zip"):
                    return url
        return None


# ==================== SINGLETON ====================

_client: TSEClient | None = None


def get_tse_client() -> TSEClient:
    """
    Retorna a instância singleton do TSEClient.

    Returns:
        Instância global do TSEClient.
    """
    global _client
    if _client is None:
        _client = TSEClient()
    return _client


async def close_tse_client():
    """Fecha e reseta o cliente global."""
    global _client
    if _client:
        await _client.close()
        _client = None


# ==================== FACADE FUNCTIONS ====================

async def get_eleicoes_ordinarias() -> list[dict]:
    """Facade: lista eleições ordinárias."""
    return await get_tse_client().get_eleicoes_ordinarias()


async def get_candidatos(ano: int, municipio_cod: str, eleicao_id: str, cargo_cod: str) -> list[dict]:
    """Facade: lista candidatos."""
    return await get_tse_client().get_candidatos(ano, municipio_cod, eleicao_id, cargo_cod)


async def get_datasets() -> list[str]:
    """Facade: lista todos os datasets."""
    return await get_tse_client().get_datasets()


async def buscar_datasets(termo: str) -> list[dict]:
    """Facade: busca datasets por termo."""
    return await get_tse_client().buscar_datasets(termo)


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)