quantilica-cli 0.3.2__tar.gz → 0.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. quantilica_cli-0.8.0/.githooks/pre-push +30 -0
  2. quantilica_cli-0.8.0/.github/workflows/test.yml +55 -0
  3. quantilica_cli-0.8.0/CHANGELOG.md +105 -0
  4. {quantilica_cli-0.3.2 → quantilica_cli-0.8.0}/PKG-INFO +2 -2
  5. {quantilica_cli-0.3.2 → quantilica_cli-0.8.0}/pyproject.toml +2 -2
  6. {quantilica_cli-0.3.2 → quantilica_cli-0.8.0}/src/quantilica/cli/cli.py +3 -1
  7. quantilica_cli-0.8.0/src/quantilica/cli/health.py +188 -0
  8. quantilica_cli-0.8.0/src/quantilica/cli/sdk.py +789 -0
  9. quantilica_cli-0.8.0/tests/test_health.py +208 -0
  10. quantilica_cli-0.8.0/tests/test_sdk.py +277 -0
  11. quantilica_cli-0.8.0/tests/test_sdk_onda_a2.py +295 -0
  12. quantilica_cli-0.3.2/.github/workflows/test.yml +0 -38
  13. quantilica_cli-0.3.2/CHANGELOG.md +0 -46
  14. quantilica_cli-0.3.2/src/quantilica/cli/sdk.py +0 -335
  15. {quantilica_cli-0.3.2 → quantilica_cli-0.8.0}/.githooks/pre-commit +0 -0
  16. {quantilica_cli-0.3.2 → quantilica_cli-0.8.0}/.github/workflows/publish.yml +0 -0
  17. {quantilica_cli-0.3.2 → quantilica_cli-0.8.0}/.gitignore +0 -0
  18. {quantilica_cli-0.3.2 → quantilica_cli-0.8.0}/LICENSE +0 -0
  19. {quantilica_cli-0.3.2 → quantilica_cli-0.8.0}/README.md +0 -0
  20. {quantilica_cli-0.3.2 → quantilica_cli-0.8.0}/src/quantilica/cli/__init__.py +0 -0
  21. {quantilica_cli-0.3.2 → quantilica_cli-0.8.0}/src/quantilica/cli/manifests.py +0 -0
  22. {quantilica_cli-0.3.2 → quantilica_cli-0.8.0}/src/quantilica/cli/progress.py +0 -0
  23. {quantilica_cli-0.3.2 → quantilica_cli-0.8.0}/src/quantilica/cli/sources.py +0 -0
  24. {quantilica_cli-0.3.2 → quantilica_cli-0.8.0}/src/quantilica/cli/ui.py +0 -0
  25. {quantilica_cli-0.3.2 → quantilica_cli-0.8.0}/src/quantilica/py.typed +0 -0
  26. {quantilica_cli-0.3.2 → quantilica_cli-0.8.0}/tests/__init__.py +0 -0
  27. {quantilica_cli-0.3.2 → quantilica_cli-0.8.0}/tests/test_manifests.py +0 -0
  28. {quantilica_cli-0.3.2 → quantilica_cli-0.8.0}/tests/test_sources.py +0 -0
  29. {quantilica_cli-0.3.2 → quantilica_cli-0.8.0}/tests/test_ui.py +0 -0
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env bash
2
+ # Barra o push se a árvore inteira não passar em lint/format.
3
+ #
4
+ # Complementa o pre-commit (que só vê .py staged): pega dívida de lint
5
+ # pré-existente e commits que não tocam Python — foi assim que E501 dos
6
+ # sweeps de 2026-08-14 ficou vermelho no CI por semanas e um commit de
7
+ # pyproject re-ativou CI vermelho no inmet (2026-08-31).
8
+ #
9
+ # Instalação (uma vez por clone): git config core.hooksPath .githooks
10
+ # (o bootstrap.sh faz isso automaticamente).
11
+ set -euo pipefail
12
+
13
+ cd "$(git rev-parse --show-toplevel)"
14
+
15
+ [ -d src ] || exit 0
16
+ if [ -d tests ]; then
17
+ TARGETS="src/ tests/"
18
+ else
19
+ TARGETS="src/"
20
+ fi
21
+
22
+ if ! uv run --no-sync ruff --version >/dev/null 2>&1; then
23
+ echo "pre-push: ruff indisponível no ambiente — rodando uv sync --group dev" >&2
24
+ uv sync --group dev
25
+ fi
26
+
27
+ uv run --no-sync ruff check ${TARGETS}
28
+ uv run --no-sync ruff format --check ${TARGETS}
29
+
30
+ echo "pre-push: lint/format da árvore OK."
@@ -0,0 +1,55 @@
1
+ name: Test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+ workflow_dispatch:
9
+
10
+ jobs:
11
+ test:
12
+ name: Test (Python ${{ matrix.python-version }})
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ fail-fast: false
16
+ matrix:
17
+ python-version: ["3.12", "3.13"]
18
+
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+
22
+ - name: Install uv
23
+ uses: astral-sh/setup-uv@v5
24
+ with:
25
+ enable-cache: true
26
+
27
+ - name: Set up Python ${{ matrix.python-version }}
28
+ run: uv python install ${{ matrix.python-version }}
29
+
30
+ # O índice Quantilica (GitHub Pages) já deu flake de DNS no runner
31
+ # (2026-08-23: rtn e inmet). Três tentativas removem o flake sem
32
+ # esconder erro real de resolução.
33
+ - name: Install dependencies
34
+ run: |
35
+ for i in 1 2 3; do
36
+ if uv sync --group dev --python ${{ matrix.python-version }} \
37
+ --index https://index.quantilica.com/simple/ \
38
+ --index-strategy unsafe-best-match; then
39
+ exit 0
40
+ fi
41
+ echo "::warning::uv sync falhou (tentativa $i/3); nova tentativa em 10s"
42
+ sleep 10
43
+ done
44
+ exit 1
45
+
46
+ # Cada `uv run` sem --no-sync re-resolve o lockfile virtual e REMOVE os
47
+ # extras instalados no passo de sync acima (polars/openpyxl já caíram
48
+ # assim: anp 2026-08-29). Todos os passos usam --no-sync.
49
+ - name: Lint with ruff
50
+ run: |
51
+ uv run --no-sync ruff check src/ tests/
52
+ uv run --no-sync ruff format --check src/ tests/
53
+
54
+ - name: Run tests
55
+ run: uv run --no-sync pytest
@@ -0,0 +1,105 @@
1
+ # Changelog
2
+
3
+ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
4
+
5
+ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
6
+ e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).
7
+
8
+ ## [0.8.0] - 2026-10-03
9
+
10
+ Onda A.2 do plano `2026-10-03-padronizacao-core-e-consolidacao-fetchers` —
11
+ decorators de ciclo de vida no SDK (`quantilica.cli.sdk`) e abstração de
12
+ pré-visualização tabular de sincronização.
13
+
14
+ ### Adicionado
15
+ - `FetcherApp.command_convert(func)`: decorator que registra o subcomando
16
+ `convert` com flags canônicas (`-i/--input`, `-o/--output`, `--verbose`,
17
+ padrões derivados de `default_output`). Configura logging Rich, trata
18
+ `ImportError` graciosamente (sugerindo `pip install {nome}[analysis]`,
19
+ saída com código 1) e exibe confirmação com check verde.
20
+ - `FetcherApp.command_pipeline(func)`: decorator que registra o subcomando
21
+ `pipeline` encadeando sincronização (passo 1/2, via `sync`) e conversão
22
+ analítica (passo 2/2, via `func`). Opções: grupos, `--output`,
23
+ `--parquet-dir`, `--workers`, `--dry-run` (interrompe antes do passo 2) e
24
+ `--verbose`.
25
+ - `FetcherApp.command_archive(func)`: decorator que registra o subcomando
26
+ `archive` para arquivamento histórico, com o mesmo padrão de flags e
27
+ tratamento gracioso de `ImportError`.
28
+ - `SyncPlanItem`/`SyncPlan`: dataclasses (imutáveis) para planejamento de
29
+ sincronização, com `SyncPlan.render_table(console=None)` que renderiza uma
30
+ tabela Rich (`Dataset | Partição | Arquivo | URL`) e o sumário `Total: X
31
+ arquivos planejados. Y ignorados fora de cobertura.` Importados do
32
+ `quantilica.cli.sdk`.
33
+ - `quantilica health`: subcomando de diagnóstico que faz sondas HTTP leves
34
+ (HEAD com fallback para GET, timeout padrão 5s) contra as fontes canônicas
35
+ do ecossistema — BCB SGS, SIDRA, Tesouro Direto, Comex e INMET — em
36
+ paralelo, com saída em tabela Rich (`Fonte | Estado | Latência | HTTP`) ou
37
+ em JSON estruturado via `--json`. Usa `quantilica.core.http` para o probe.
38
+
39
+ ## [0.7.0] - 2026-10-02
40
+
41
+ Onda 2 — extensões do SDK (`quantilica.cli.sdk`) para eliminação de
42
+ boilerplate nos fetchers (decisão
43
+ `2026-10-02-padronizacao-e-deduplicacao-fetchers`).
44
+
45
+ ### Adicionado
46
+ - `DataRepository` canônico no SDK (baseado em `StampedDataRepository` do core)
47
+ com `path_for_entry(entry, last_modified=...)`, estabelecendo a convenção
48
+ única de layout: `{dataset_id}/{slug}[@{partition}]@{YYYYMMDD}.{ext}`.
49
+ - `default_path_builder(output_dir, entry, last_modified)`: path builder
50
+ canônico para fetchers que não fornecem um próprio.
51
+ - `FetcherApp.attach_command(cmd_func, name=None, **kwargs)`: registro limpo de
52
+ subcomandos customizados (`convert`, `pipeline`, `archive`) sem subclassificar
53
+ e sobrescrever `_build_commands`.
54
+ - `FetcherApp(build_default_commands=False)`: instancie o app sem os comandos
55
+ padrão `sync`/`list` (fim do padrão `def _build_commands(): pass`).
56
+ - `make_resolve_groups(groups_dict, aliases_dict)`: helper que constrói
57
+ resolver de grupos/aliases para comandos customizados (dedup, ordem
58
+ declarada, erro em grupos desconhecidos); o comando `sync` padrão agora o
59
+ usa.
60
+ - Commit `feat(sdk)`: `default_client()` já com `emulate_browser` e pooling
61
+ keep-alive por worker em `download_datasets` (anteriomente não documentado).
62
+
63
+ ### Alterado
64
+ - `path_builder` no `FetcherApp` é agora opcional (default:
65
+ `default_path_builder`).
66
+
67
+ ## [0.3.2] - 2026-08-22
68
+
69
+ ### Corrigido
70
+ - **Crítico:** wheels publicados desde a 0.3.0 vinham **sem os módulos do pacote** — o diretório `quantilica/cli/` não era incluído no build por configuração incorreta do hatchling (`sources` na seção global e `packages` apontando para o subpacote). Instalações via pip/uv reportavam sucesso, mas `import quantilica.cli` falhava em qualquer ambiente não-editable. Configuração realinhada ao padrão dos pacotes irmãos (`packages = ["src/quantilica"]` dentro de `[tool.hatch.build.targets.wheel]`).
71
+
72
+ ## [0.3.1] - 2026-08-22
73
+
74
+ ### Corrigido
75
+ - `DEFAULT_INDEX_URL` apontado para `https://index.quantilica.com/simple/` — a URL anterior (`quantilica.com/quantilica-index/`) parou de ser servida quando o domínio passou ao portal, quebrando o `quantilica install` para fetchers fora do PyPI legado (detalhes no ADR de distribuição de 2026-08-22).
76
+ - `install`/`uninstall` agora mesclam o registro remoto (`sources.json`) com o registro local, resolvendo também nomes canônicos do índice (ex.: `tesouro-direto`, além de `td`).
77
+
78
+ ## [0.3.0] - 2026-08-10
79
+
80
+ ### Adicionado
81
+ - Extensão da `FetcherApp` (`sdk.py`) com suporte opcional para `FtpClient`.
82
+ - Exposição do método `download_datasets` na `FetcherApp` para uso por comandos Typer customizados nos plugins.
83
+ - Aceitação e passagem do parâmetro `aliases_dict` para personalização total dos subcomandos por fetcher.
84
+
85
+ ### Alterado
86
+ - Padrão de metadados do pacote portado integralmente para a PEP 639 (licença) e PEP 561 (tipagem estática).
87
+
88
+ ## [0.2.2] - 2026-07-28
89
+ *(Release retroativo não documentado)*
90
+
91
+ ## [0.2.0] - 2026-07-15
92
+ *(Release retroativo não documentado)*
93
+
94
+ ## [0.1.0] - 2026-06-04
95
+
96
+ Primeira entrada em formato Keep a Changelog; documenta o estado do pacote nesta
97
+ versão.
98
+
99
+ ### Adicionado
100
+
101
+ - CLI unificada `quantilica` que descobre e monta os fetchers instalados via
102
+ entry points `quantilica.fetchers`, sem depender diretamente dos pacotes de
103
+ fetcher.
104
+ - Comando `list-sources` e montagem automática dos sub-apps Typer de cada fetcher
105
+ instalado (`quantilica <fonte> ...`).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: quantilica-cli
3
- Version: 0.3.2
3
+ Version: 0.8.0
4
4
  Summary: Unified CLI for Quantilica open data fetchers
5
5
  Author-email: "Komesu, D.K." <daniel@dkko.me>
6
6
  License-Expression: MIT
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.12
16
16
  Classifier: Programming Language :: Python :: 3.13
17
17
  Classifier: Typing :: Typed
18
18
  Requires-Python: >=3.12
19
- Requires-Dist: quantilica-core>=0.4.0
19
+ Requires-Dist: quantilica-core>=0.7.0
20
20
  Requires-Dist: rich>=13.0.0
21
21
  Requires-Dist: typer>=0.15.0
22
22
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "quantilica-cli"
3
- version = "0.3.2"
3
+ version = "0.8.0"
4
4
  description = "Unified CLI for Quantilica open data fetchers"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Komesu, D.K.", email = "daniel@dkko.me" }]
@@ -20,7 +20,7 @@ classifiers = [
20
20
  "Programming Language :: Python :: 3.13",
21
21
  ]
22
22
  dependencies = [
23
- "quantilica-core>=0.4.0",
23
+ "quantilica-core>=0.7.0",
24
24
  "rich>=13.0.0",
25
25
  "typer>=0.15.0",
26
26
  ]
@@ -13,6 +13,7 @@ from rich.logging import RichHandler
13
13
  from rich.table import Table
14
14
 
15
15
  from quantilica.cli import __version__
16
+ from quantilica.cli.health import cmd_health
16
17
  from quantilica.cli.manifests import app as manifests_app
17
18
  from quantilica.cli.sources import (
18
19
  app as sources_app,
@@ -35,10 +36,11 @@ app = typer.Typer(
35
36
  app.add_typer(manifests_app, name="manifests")
36
37
  app.add_typer(sources_app, name="sources")
37
38
 
38
- # Adiciona comandos top-level install, uninstall e doctor
39
+ # Adiciona comandos top-level install, uninstall, doctor e health
39
40
  app.command("install")(cmd_install)
40
41
  app.command("uninstall")(cmd_uninstall)
41
42
  app.command("doctor")(cmd_doctor)
43
+ app.command("health")(cmd_health)
42
44
 
43
45
  console = Console()
44
46
 
@@ -0,0 +1,188 @@
1
+ """Comando `quantilica health` — sondas de disponibilidade das fontes.
2
+
3
+ Realiza sondas HTTP leves (HEAD com fallback para GET) contra as fontes
4
+ canônicas de dados do ecossistema Quantilica e reporta o resultado em
5
+ tabela Rich ou JSON estruturado.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import concurrent.futures
11
+ import json
12
+ import time
13
+ from typing import Annotated
14
+
15
+ import httpx2
16
+ import typer
17
+ from rich.console import Console
18
+ from rich.table import Table
19
+
20
+ console = Console()
21
+
22
+ DEFAULT_TIMEOUT = 5.0
23
+ USER_AGENT = "quantilica-cli (health)"
24
+
25
+ # Lista canônica de fontes sondadas (nome, URL de sonda leve).
26
+ HEALTH_SOURCES: list[tuple[str, str]] = [
27
+ ("bcb", "https://api.bcb.gov.br/dados/serie/bcdata.sgs.1/dados?formato=json"),
28
+ ("sidra", "https://servicodados.ibge.gov.br/api/v3/calendario"),
29
+ (
30
+ "tesouro_direto",
31
+ "https://www.tesourotransparente.gov.br/ckan/api/3/action/package_search?rows=1",
32
+ ),
33
+ ("comex", "https://balanca.economia.gov.br"),
34
+ ("inmet", "https://apitempo.inmet.gov.br/estacoes"),
35
+ ]
36
+
37
+
38
+ def _make_client(timeout: float) -> httpx2.Client:
39
+ """Cria o cliente HTTP usado pelas sondas.
40
+
41
+ Args:
42
+ timeout: Timeout (segundos) aplicado a cada requisição.
43
+
44
+ Returns:
45
+ Um httpx2.Client configurado com redirects e User-Agent da CLI.
46
+ """
47
+ return httpx2.Client(
48
+ timeout=timeout,
49
+ follow_redirects=True,
50
+ headers={"User-Agent": USER_AGENT},
51
+ )
52
+
53
+
54
+ def _probe(client: httpx2.Client, name: str, url: str) -> dict[str, object]:
55
+ """Executa uma sonda HEAD (com fallback GET) contra uma fonte.
56
+
57
+ Args:
58
+ client: O cliente HTTP a ser usado.
59
+ name: Nome canônico da fonte.
60
+ url: URL alvo da sonda.
61
+
62
+ Returns:
63
+ Dicioário com name, url, status, http_status, latency_ms e error.
64
+ """
65
+ start = time.perf_counter()
66
+ try:
67
+ response = client.head(url)
68
+ # Muitos endpoints não implementam HEAD — fallback para GET leve.
69
+ if response.status_code >= 400:
70
+ response = client.get(url)
71
+ latency_ms = (time.perf_counter() - start) * 1000.0
72
+ if response.status_code < 400:
73
+ return {
74
+ "name": name,
75
+ "url": url,
76
+ "status": "ok",
77
+ "http_status": response.status_code,
78
+ "latency_ms": round(latency_ms, 1),
79
+ "error": None,
80
+ }
81
+ return {
82
+ "name": name,
83
+ "url": url,
84
+ "status": "falha",
85
+ "http_status": response.status_code,
86
+ "latency_ms": round(latency_ms, 1),
87
+ "error": f"HTTP {response.status_code}",
88
+ }
89
+ except Exception as exc:
90
+ latency_ms = (time.perf_counter() - start) * 1000.0
91
+ return {
92
+ "name": name,
93
+ "url": url,
94
+ "status": "falha",
95
+ "http_status": None,
96
+ "latency_ms": round(latency_ms, 1),
97
+ "error": f"{type(exc).__name__}: {exc}",
98
+ }
99
+
100
+
101
+ def _run_probes(timeout: float, fail_fast: bool) -> list[dict[str, object]]:
102
+ """Sonda todas as fontes, em sequência (fail-fast) ou em paralelo.
103
+
104
+ Args:
105
+ timeout: Timeout por requisição, em segundos.
106
+ fail_fast: Se True, interrompe as sondas na primeira falha.
107
+
108
+ Returns:
109
+ A lista de resultados por fonte.
110
+ """
111
+ with _make_client(timeout=timeout) as client:
112
+ if fail_fast:
113
+ results = []
114
+ for name, url in HEALTH_SOURCES:
115
+ result = _probe(client, name, url)
116
+ results.append(result)
117
+ if result["status"] != "ok":
118
+ break
119
+ return results
120
+
121
+ with concurrent.futures.ThreadPoolExecutor(
122
+ max_workers=len(HEALTH_SOURCES)
123
+ ) as pool:
124
+ futures = [
125
+ (name, pool.submit(_probe, client, name, url))
126
+ for name, url in HEALTH_SOURCES
127
+ ]
128
+ return [future.result() for _, future in futures]
129
+
130
+
131
+ def _render_table(results: list[dict[str, object]]) -> None:
132
+ """Renderiza o relatório de health como tabela Rich.
133
+
134
+ Args:
135
+ results: Resultados das sondas.
136
+ """
137
+ table = Table(title="Health — fontes de dados", show_header=True)
138
+ table.add_column("Fonte", style="cyan")
139
+ table.add_column("Status")
140
+ table.add_column("Código", justify="right")
141
+ table.add_column("Latência (ms)", justify="right")
142
+
143
+ for r in results:
144
+ if r["status"] == "ok":
145
+ status = "[green]OK[/green]"
146
+ else:
147
+ status = "[red]FALHA[/red]"
148
+ code = str(r["http_status"]) if r["http_status"] is not None else "-"
149
+ latency = str(r["latency_ms"]) if r["latency_ms"] is not None else "-"
150
+ table.add_row(str(r["name"]), status, code, latency)
151
+
152
+ console.print(table)
153
+
154
+
155
+ def cmd_health(
156
+ json_output: Annotated[
157
+ bool,
158
+ typer.Option(
159
+ "--json",
160
+ help="Saída em JSON estruturado em vez de tabela.",
161
+ ),
162
+ ] = False,
163
+ fail_fast: Annotated[
164
+ bool,
165
+ typer.Option(
166
+ "--fail-fast",
167
+ help="Interrompe as sondas na primeira falha e encerra com código 1.",
168
+ ),
169
+ ] = False,
170
+ timeout: Annotated[
171
+ float,
172
+ typer.Option(
173
+ "--timeout",
174
+ help="Timeout (s) aplicado a cada sonda HTTP.",
175
+ ),
176
+ ] = DEFAULT_TIMEOUT,
177
+ ) -> None:
178
+ """Verifica a disponibilidade das fontes de dados (sondas HTTP leves)."""
179
+ results = _run_probes(timeout, fail_fast)
180
+
181
+ if json_output:
182
+ status = "ok" if all(r["status"] == "ok" for r in results) else "degraded"
183
+ print(json.dumps({"status": status, "sources": results}))
184
+ else:
185
+ _render_table(results)
186
+
187
+ if fail_fast and any(r["status"] != "ok" for r in results):
188
+ raise typer.Exit(code=1)