quantilica-cli 0.11.0__tar.gz → 0.13.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.
- quantilica_cli-0.13.0/.github/workflows/public-hygiene.yml +104 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/CHANGELOG.md +29 -5
- {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/PKG-INFO +1 -1
- {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/pyproject.toml +1 -1
- {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/src/quantilica/cli/sdk.py +257 -52
- {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/src/quantilica/cli/sources.py +69 -15
- {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/tests/test_sdk.py +222 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/tests/test_sdk_force_probe.py +89 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/tests/test_sdk_onda_a2.py +8 -5
- {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/tests/test_sources.py +31 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/.githooks/pre-commit +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/.githooks/pre-push +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/.github/workflows/publish.yml +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/.github/workflows/test.yml +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/.gitignore +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/LICENSE +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/README.md +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/src/quantilica/cli/__init__.py +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/src/quantilica/cli/cli.py +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/src/quantilica/cli/health.py +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/src/quantilica/cli/manifests.py +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/src/quantilica/cli/ui.py +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/src/quantilica/py.typed +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/tests/__init__.py +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/tests/test_health.py +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/tests/test_manifests.py +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/tests/test_package_hygiene.py +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/tests/test_sdk_mes_string.py +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/tests/test_ui.py +0 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
name: Public hygiene
|
|
2
|
+
|
|
3
|
+
# Veta termos internos (planos, decisões, repositórios privados, infraestrutura)
|
|
4
|
+
# em CHANGELOG.md, README e nas mensagens de commit de PRs. O repositório é
|
|
5
|
+
# público: o que entra aqui fica no histórico para sempre.
|
|
6
|
+
# Regra e lista de termos: docs/normas/changelog.md, seção 5.1.
|
|
7
|
+
# Falso positivo legítimo: termine a linha com `<!-- hygiene-ok -->`.
|
|
8
|
+
|
|
9
|
+
on:
|
|
10
|
+
push:
|
|
11
|
+
branches: [main]
|
|
12
|
+
pull_request:
|
|
13
|
+
branches: [main]
|
|
14
|
+
workflow_dispatch:
|
|
15
|
+
|
|
16
|
+
permissions:
|
|
17
|
+
contents: read
|
|
18
|
+
|
|
19
|
+
jobs:
|
|
20
|
+
hygiene:
|
|
21
|
+
name: Termos internos
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
steps:
|
|
24
|
+
- uses: actions/checkout@v4
|
|
25
|
+
with:
|
|
26
|
+
fetch-depth: 0
|
|
27
|
+
|
|
28
|
+
- name: Escrever lista de termos
|
|
29
|
+
run: |
|
|
30
|
+
cat > "$RUNNER_TEMP/termos-comuns.txt" <<'EOF'
|
|
31
|
+
\bOnda\b
|
|
32
|
+
\bADR\b
|
|
33
|
+
\bplanning/
|
|
34
|
+
\bplano (`|[a-z0-9-]+-[a-z0-9-]+|§)
|
|
35
|
+
\bPlano:
|
|
36
|
+
uploader
|
|
37
|
+
\bCAS\b
|
|
38
|
+
\bVPS\b
|
|
39
|
+
crontab
|
|
40
|
+
\bcrons?\b
|
|
41
|
+
systemd
|
|
42
|
+
runs\.csv
|
|
43
|
+
quantilica-(workers|cloud|web|events|portal|manifests-db)
|
|
44
|
+
bcb-sgs-app
|
|
45
|
+
rtn-app
|
|
46
|
+
crons-dashboard
|
|
47
|
+
datasus-analytics
|
|
48
|
+
[a-z-]+-metadata-db
|
|
49
|
+
tddata-db
|
|
50
|
+
dev-plugins
|
|
51
|
+
\bOKF\b
|
|
52
|
+
Flamingo
|
|
53
|
+
Alpaca
|
|
54
|
+
Hedgehog
|
|
55
|
+
Sloth
|
|
56
|
+
\bFish\b
|
|
57
|
+
Telegram
|
|
58
|
+
Umami
|
|
59
|
+
Cloudflare
|
|
60
|
+
(^|[^[:alnum:].])/home/[a-z]+
|
|
61
|
+
\.agents/
|
|
62
|
+
CLAUDE\.md
|
|
63
|
+
AGENTS\.md
|
|
64
|
+
EOF
|
|
65
|
+
cat > "$RUNNER_TEMP/termos-arquivos.txt" <<'EOF'
|
|
66
|
+
\bworkspace\b
|
|
67
|
+
ingestão efêmera
|
|
68
|
+
retenção efêmera
|
|
69
|
+
pipeline de ingestão
|
|
70
|
+
EOF
|
|
71
|
+
cat "$RUNNER_TEMP/termos-comuns.txt" "$RUNNER_TEMP/termos-arquivos.txt" \
|
|
72
|
+
> "$RUNNER_TEMP/termos-todos.txt"
|
|
73
|
+
|
|
74
|
+
- name: CHANGELOG e README
|
|
75
|
+
run: |
|
|
76
|
+
files=$(ls CHANGELOG.md README*.md docs/changelog.md 2>/dev/null || true)
|
|
77
|
+
if [ -z "$files" ]; then
|
|
78
|
+
echo "Nenhum CHANGELOG/README para verificar."
|
|
79
|
+
exit 0
|
|
80
|
+
fi
|
|
81
|
+
# shellcheck disable=SC2086
|
|
82
|
+
hits=$(grep -nIiE -f "$RUNNER_TEMP/termos-todos.txt" $files /dev/null \
|
|
83
|
+
| grep -v 'hygiene-ok' || true)
|
|
84
|
+
if [ -n "$hits" ]; then
|
|
85
|
+
echo "$hits"
|
|
86
|
+
echo "::error::Termos internos em changelog/README. Reescreva em termos públicos (docs/normas/changelog.md, seção 5.1)."
|
|
87
|
+
exit 1
|
|
88
|
+
fi
|
|
89
|
+
echo "Limpo."
|
|
90
|
+
|
|
91
|
+
- name: Mensagens de commit do PR
|
|
92
|
+
if: github.event_name == 'pull_request'
|
|
93
|
+
run: |
|
|
94
|
+
range="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"
|
|
95
|
+
msgs=$(git log --format='%h %s%n%b' "$range")
|
|
96
|
+
hits=$(printf '%s\n' "$msgs" \
|
|
97
|
+
| grep -iE -f "$RUNNER_TEMP/termos-comuns.txt" \
|
|
98
|
+
| grep -v 'hygiene-ok' || true)
|
|
99
|
+
if [ -n "$hits" ]; then
|
|
100
|
+
echo "$hits"
|
|
101
|
+
echo "::error::Termos internos em mensagem de commit. Reescreva o commit (docs/normas/changelog.md, seção 5.1)."
|
|
102
|
+
exit 1
|
|
103
|
+
fi
|
|
104
|
+
echo "Limpo."
|
|
@@ -5,6 +5,30 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
|
|
|
5
5
|
O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
|
|
6
6
|
e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).
|
|
7
7
|
|
|
8
|
+
## [0.13.0] - 2026-10-27
|
|
9
|
+
|
|
10
|
+
### Adicionado
|
|
11
|
+
|
|
12
|
+
- `convert`: novas opções `--lake` (raiz do lake de destino; padrão `lake_root()` de `quantilica.core.lake`, ou `default_output` se o core não tiver o módulo), `--dataset` (repetível) e `--force`. `-o/--output` continua aceito como alias de `--lake`. O conversor é chamado como `func(input, lake)` e recebe `dataset=` e `force=` por keyword só quando a assinatura os aceita (ou tem `**kwargs`); pedir `--dataset`/`--force` a um conversor que não os aceita sai com código 2.
|
|
13
|
+
- `convert` e `pipeline`: se o conversor devolver um objeto (ou dicionário) com `converted`, `skipped` e `errors`, o sumário é exibido, e `errors > 0` sai com código 1.
|
|
14
|
+
- `pipeline --lake` (com `--parquet-dir` mantido como alias), repassado ao conversor.
|
|
15
|
+
- `quantilica doctor`: informa, por fonte, se há conversor do lake registrado no entry point `quantilica.lake`.
|
|
16
|
+
|
|
17
|
+
### Alterado
|
|
18
|
+
|
|
19
|
+
- `pipeline`: o destino da conversão passa a ser `lake_root()` por padrão, em vez de igual a `--output`.
|
|
20
|
+
|
|
21
|
+
## [0.12.0] - 2026-10-19
|
|
22
|
+
|
|
23
|
+
### Adicionado
|
|
24
|
+
|
|
25
|
+
- `FetcherApp(source_id=...)`: `source_id` explícito, gravado nos manifests de download. Sem ele, continua derivado de `name` sem o sufixo `-fetcher`, e o `default_output` padrão passa a ser `/data/{source_id}`.
|
|
26
|
+
|
|
27
|
+
### Corrigido
|
|
28
|
+
|
|
29
|
+
- `check`: o probe `GET Range` tenta de novo (com backoff) em 429/5xx e em erros de transporte (`DEFAULT_RETRY_EXCEPTIONS`: timeout, reset de conexão) antes de desistir. Antes, uma falha transitória virava 404 e a URL saía como indisponível. Quando o probe desiste, a causa é logada em `WARNING` (`[check] probe Range falhou para <url> após N tentativa(s): ...`), e não mais em `DEBUG`.
|
|
30
|
+
- `check`: uma exceção inesperada em uma entrada (por exemplo, `OSError` ao montar o caminho local) não aborta mais o `check` inteiro com código 1. A entrada é verificada de novo uma vez e, se falhar outra vez, vira `download` com `reason=check-error: ...`, e o plano segue cobrindo as demais.
|
|
31
|
+
|
|
8
32
|
## [0.11.0] - 2026-10-14
|
|
9
33
|
|
|
10
34
|
### Adicionado
|
|
@@ -35,7 +59,7 @@ e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).
|
|
|
35
59
|
### Corrigido
|
|
36
60
|
|
|
37
61
|
- `sdk`: `check_entry` considera `local.exists() or sidecar.exists()`, preservando
|
|
38
|
-
o status de `manifest-fresh` em ambientes
|
|
62
|
+
o status de `manifest-fresh` em ambientes sem retenção local dos arquivos sem forçar
|
|
39
63
|
re-download redundante quando o dado bruto já foi espelhado.
|
|
40
64
|
|
|
41
65
|
## [0.10.0] - 2026-10-07
|
|
@@ -60,7 +84,7 @@ e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).
|
|
|
60
84
|
|
|
61
85
|
## [0.9.1] - 2026-10-04
|
|
62
86
|
|
|
63
|
-
Correção de escape inválido introduzido na v0.8.0 (
|
|
87
|
+
Correção de escape inválido introduzido na v0.8.0 (extensões do SDK).
|
|
64
88
|
|
|
65
89
|
### Corrigido
|
|
66
90
|
- `SyntaxWarning: invalid escape sequence '\['` nas mensagens de `convert`,
|
|
@@ -84,7 +108,7 @@ fetcher (wheel no GitHub Releases, entrada no índice PEP 503 do ecossistema).
|
|
|
84
108
|
|
|
85
109
|
## [0.8.0] - 2026-10-03
|
|
86
110
|
|
|
87
|
-
|
|
111
|
+
Padronização do SDK e consolidação dos fetchers —
|
|
88
112
|
decorators de ciclo de vida no SDK (`quantilica.cli.sdk`) e abstração de
|
|
89
113
|
pré-visualização tabular de sincronização.
|
|
90
114
|
|
|
@@ -115,7 +139,7 @@ pré-visualização tabular de sincronização.
|
|
|
115
139
|
|
|
116
140
|
## [0.7.0] - 2026-10-02
|
|
117
141
|
|
|
118
|
-
|
|
142
|
+
Extensões do SDK (`quantilica.cli.sdk`) para eliminação de
|
|
119
143
|
boilerplate nos fetchers (decisão
|
|
120
144
|
`2026-10-02-padronizacao-e-deduplicacao-fetchers`).
|
|
121
145
|
|
|
@@ -149,7 +173,7 @@ boilerplate nos fetchers (decisão
|
|
|
149
173
|
## [0.3.1] - 2026-08-22
|
|
150
174
|
|
|
151
175
|
### Corrigido
|
|
152
|
-
- `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
|
|
176
|
+
- `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 na decisão de distribuição de 2026-08-22).
|
|
153
177
|
- `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`).
|
|
154
178
|
|
|
155
179
|
## [0.3.0] - 2026-08-10
|
|
@@ -9,9 +9,11 @@ import concurrent.futures
|
|
|
9
9
|
import contextlib
|
|
10
10
|
import datetime as dt
|
|
11
11
|
import email.utils
|
|
12
|
+
import inspect
|
|
12
13
|
import json
|
|
13
14
|
import os
|
|
14
15
|
import threading
|
|
16
|
+
import time
|
|
15
17
|
from collections.abc import Callable, Iterable
|
|
16
18
|
from dataclasses import dataclass, field
|
|
17
19
|
from importlib.metadata import entry_points
|
|
@@ -22,6 +24,7 @@ import typer
|
|
|
22
24
|
from quantilica.core.exceptions import FetchError
|
|
23
25
|
from quantilica.core.ftp import FtpClient
|
|
24
26
|
from quantilica.core.http import (
|
|
27
|
+
DEFAULT_RETRY_EXCEPTIONS,
|
|
25
28
|
HttpClient,
|
|
26
29
|
HttpStatusError,
|
|
27
30
|
ProgressCallback,
|
|
@@ -37,6 +40,7 @@ from quantilica.core.storage import (
|
|
|
37
40
|
)
|
|
38
41
|
from rich.console import Console, Group
|
|
39
42
|
from rich.live import Live
|
|
43
|
+
from rich.markup import escape
|
|
40
44
|
from rich.rule import Rule
|
|
41
45
|
from rich.table import Table
|
|
42
46
|
|
|
@@ -165,6 +169,13 @@ def _short_remote(item: CheckPlanItem) -> str:
|
|
|
165
169
|
return remote
|
|
166
170
|
|
|
167
171
|
|
|
172
|
+
# Probe Range: novas tentativas em status ou erro de transporte transitório
|
|
173
|
+
# (``DEFAULT_RETRY_EXCEPTIONS``) antes de desistir.
|
|
174
|
+
_PROBE_RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
175
|
+
_PROBE_ATTEMPTS = 3
|
|
176
|
+
_PROBE_BACKOFF_S = 1.0
|
|
177
|
+
|
|
178
|
+
|
|
168
179
|
class _ProbeNotFoundError(Exception):
|
|
169
180
|
"""O GET de probe Range respondeu 404 (URL removida no servidor)."""
|
|
170
181
|
|
|
@@ -513,6 +524,9 @@ class FetcherApp:
|
|
|
513
524
|
:func:`default_path_builder` (canonical ``DataRepository`` layout).
|
|
514
525
|
default_output: Default output directory path.
|
|
515
526
|
client: HTTP or FTP client instance. Defaults to default_client().
|
|
527
|
+
source_id: Source ID recorded in download manifests. Defaults to
|
|
528
|
+
``name`` without the ``-fetcher`` suffix. When ``default_output``
|
|
529
|
+
is omitted it is derived as ``/data/{source_id}``.
|
|
516
530
|
build_default_commands: If True (default), registers the built-in
|
|
517
531
|
``sync`` and ``list`` commands. Set to False to register only
|
|
518
532
|
custom commands via :meth:`attach_command`.
|
|
@@ -530,6 +544,7 @@ class FetcherApp:
|
|
|
530
544
|
path_builder: Callable[[Path, dict[str, Any], dt.date | None], Path]
|
|
531
545
|
| None = None,
|
|
532
546
|
default_output: Path | None = None,
|
|
547
|
+
source_id: str | None = None,
|
|
533
548
|
client: HttpClient | FtpClient | None = None,
|
|
534
549
|
build_default_commands: bool = True,
|
|
535
550
|
):
|
|
@@ -539,9 +554,10 @@ class FetcherApp:
|
|
|
539
554
|
self.aliases = aliases_dict
|
|
540
555
|
self.list_datasets = list_datasets
|
|
541
556
|
self.path_builder = path_builder or default_path_builder
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
)
|
|
557
|
+
# source_id explícito vence; senão deriva de `name` sem o sufixo
|
|
558
|
+
# "-fetcher" (comportamento histórico).
|
|
559
|
+
self.source_id = source_id or name.replace("-fetcher", "")
|
|
560
|
+
self.default_output = default_output or Path(f"/data/{self.source_id}")
|
|
545
561
|
self.client = client or default_client()
|
|
546
562
|
|
|
547
563
|
self.all_group_keys = list(self.groups.keys())
|
|
@@ -594,24 +610,132 @@ class FetcherApp:
|
|
|
594
610
|
pass
|
|
595
611
|
return f"quantilica install {key} --extra analysis"
|
|
596
612
|
|
|
597
|
-
def
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
613
|
+
def _default_lake(self) -> Path:
|
|
614
|
+
"""Raiz padrão do lake: ``lake_root()`` do core, se disponível.
|
|
615
|
+
|
|
616
|
+
``quantilica.core.lake`` só existe a partir de versões recentes do core;
|
|
617
|
+
sem ele, cai no ``default_output`` (comportamento anterior).
|
|
618
|
+
"""
|
|
619
|
+
try:
|
|
620
|
+
from quantilica.core.lake import lake_root
|
|
621
|
+
except ImportError: # pragma: no cover - core sem o módulo lake
|
|
622
|
+
return self.default_output
|
|
623
|
+
return lake_root()
|
|
624
|
+
|
|
625
|
+
def _run_convert(
|
|
626
|
+
self,
|
|
627
|
+
func: Callable[..., Any],
|
|
628
|
+
input: Path,
|
|
629
|
+
lake: Path,
|
|
630
|
+
*,
|
|
631
|
+
dataset: list[str] | None = None,
|
|
632
|
+
force: bool = False,
|
|
633
|
+
) -> Any:
|
|
634
|
+
"""Chama ``func`` com os argumentos que ela aceita.
|
|
635
|
+
|
|
636
|
+
Conversores legados têm a forma ``func(input, lake)``. ``dataset`` e
|
|
637
|
+
``force`` só são repassados (como keyword) quando a assinatura os aceita
|
|
638
|
+
ou tem ``**kwargs``. Se o usuário pediu um deles e ``func`` não o
|
|
639
|
+
aceita, sai com código 2 em vez de ignorar a opção em silêncio.
|
|
640
|
+
"""
|
|
641
|
+
try:
|
|
642
|
+
params = inspect.signature(func).parameters
|
|
643
|
+
except (TypeError, ValueError): # pragma: no cover - builtins/C callables
|
|
644
|
+
return func(input, lake)
|
|
645
|
+
var_kw = any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values())
|
|
646
|
+
wanted = {"dataset": dataset or None, "force": force or None}
|
|
647
|
+
kwargs: dict[str, Any] = {}
|
|
648
|
+
for name, value in wanted.items():
|
|
649
|
+
if name in params or var_kw:
|
|
650
|
+
kwargs[name] = dataset if name == "dataset" else force
|
|
651
|
+
elif value is not None:
|
|
652
|
+
get_console().print(
|
|
653
|
+
f"[red]Erro:[/red] este conversor não suporta --{name}."
|
|
654
|
+
)
|
|
655
|
+
raise typer.Exit(2)
|
|
656
|
+
return func(input, lake, **kwargs)
|
|
657
|
+
|
|
658
|
+
@staticmethod
|
|
659
|
+
def _convert_counts(result: Any) -> dict[str, Any] | None:
|
|
660
|
+
"""Extrai ``converted``/``skipped``/``errors`` do retorno de um conversor.
|
|
601
661
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
662
|
+
Aceita um objeto com esses atributos ou um dicionário de contagens.
|
|
663
|
+
Valores podem ser inteiros ou coleções (usa-se o tamanho nas contagens).
|
|
664
|
+
|
|
665
|
+
Returns:
|
|
666
|
+
``{"converted": int, "skipped": int, "errors": int,
|
|
667
|
+
"error_items": list}`` ou ``None`` se o retorno não tiver o formato.
|
|
668
|
+
"""
|
|
669
|
+
keys = ("converted", "skipped", "errors")
|
|
670
|
+
if isinstance(result, dict):
|
|
671
|
+
raw = {k: result.get(k) for k in keys}
|
|
672
|
+
present = any(k in result for k in keys)
|
|
673
|
+
else:
|
|
674
|
+
raw = {k: getattr(result, k, None) for k in keys}
|
|
675
|
+
present = any(hasattr(result, k) for k in keys)
|
|
676
|
+
if not present:
|
|
677
|
+
return None
|
|
678
|
+
|
|
679
|
+
def count(value: Any) -> int:
|
|
680
|
+
if value is None:
|
|
681
|
+
return 0
|
|
682
|
+
if isinstance(value, int | float):
|
|
683
|
+
return int(value)
|
|
684
|
+
return len(value)
|
|
685
|
+
|
|
686
|
+
errors = raw["errors"]
|
|
687
|
+
items = (
|
|
688
|
+
list(errors)
|
|
689
|
+
if errors is not None and not isinstance(errors, int | float)
|
|
690
|
+
else []
|
|
691
|
+
)
|
|
692
|
+
return {
|
|
693
|
+
"converted": count(raw["converted"]),
|
|
694
|
+
"skipped": count(raw["skipped"]),
|
|
695
|
+
"errors": count(errors),
|
|
696
|
+
"error_items": items,
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
def _report_convert(self, result: Any, console: Console) -> bool:
|
|
700
|
+
"""Imprime o sumário da conversão. Retorna ``True`` se houve erros."""
|
|
701
|
+
counts = self._convert_counts(result)
|
|
702
|
+
if counts is None:
|
|
703
|
+
return False
|
|
704
|
+
table = Table(show_header=False, box=None, pad_edge=False)
|
|
705
|
+
table.add_row("[green]convertidos[/green]", str(counts["converted"]))
|
|
706
|
+
table.add_row("[dim]pulados[/dim]", str(counts["skipped"]))
|
|
707
|
+
table.add_row(
|
|
708
|
+
"[red]erros[/red]" if counts["errors"] else "[dim]erros[/dim]",
|
|
709
|
+
str(counts["errors"]),
|
|
710
|
+
)
|
|
711
|
+
console.print(table)
|
|
712
|
+
for item in counts["error_items"]:
|
|
713
|
+
console.print(f" [red]✗[/red] {escape(str(item))}")
|
|
714
|
+
return counts["errors"] > 0
|
|
715
|
+
|
|
716
|
+
def command_convert(self, func: Callable[..., Any]) -> Callable[..., Any]:
|
|
717
|
+
"""Register the standard ``convert`` subcommand (raw → lake).
|
|
718
|
+
|
|
719
|
+
The generated command exposes ``-i/--input``, ``--lake`` (aliases
|
|
720
|
+
``-o``/``--output``; default ``lake_root()``), ``--dataset`` (repeatable),
|
|
721
|
+
``--force`` and ``--verbose``. ``func`` is called as
|
|
722
|
+
``func(input, lake, dataset=..., force=...)``; the keywords are only
|
|
723
|
+
passed when its signature accepts them, so legacy ``func(input, output)``
|
|
724
|
+
converters keep working.
|
|
725
|
+
|
|
726
|
+
If ``func`` returns an object (or dict) with ``converted``, ``skipped``
|
|
727
|
+
and ``errors``, a summary is rendered and the command exits with code 1
|
|
728
|
+
when there are errors. An ``ImportError`` propagating from ``func`` is
|
|
729
|
+
treated as missing analytical extras and exits with code 1.
|
|
606
730
|
|
|
607
731
|
Args:
|
|
608
|
-
func: Callable
|
|
609
|
-
the conversion.
|
|
732
|
+
func: Callable performing the conversion.
|
|
610
733
|
|
|
611
734
|
Returns:
|
|
612
735
|
The original ``func`` (the method can be used as a decorator).
|
|
613
736
|
"""
|
|
614
737
|
console = get_console()
|
|
738
|
+
default_lake = self._default_lake()
|
|
615
739
|
|
|
616
740
|
@self.app.command("convert")
|
|
617
741
|
def convert(
|
|
@@ -623,26 +747,47 @@ class FetcherApp:
|
|
|
623
747
|
help="Diretório de origem com arquivos brutos",
|
|
624
748
|
),
|
|
625
749
|
] = self.default_output,
|
|
626
|
-
|
|
750
|
+
lake: Annotated[
|
|
627
751
|
Path,
|
|
628
|
-
typer.Option(
|
|
629
|
-
|
|
752
|
+
typer.Option(
|
|
753
|
+
"--lake",
|
|
754
|
+
"-o",
|
|
755
|
+
"--output",
|
|
756
|
+
help="Raiz do lake de destino (padrão: QUANTILICA_LAKE_DIR)",
|
|
757
|
+
),
|
|
758
|
+
] = default_lake,
|
|
759
|
+
dataset: Annotated[
|
|
760
|
+
list[str] | None,
|
|
761
|
+
typer.Option(
|
|
762
|
+
"--dataset", help="Filtrar dataset(s) específico(s) a converter"
|
|
763
|
+
),
|
|
764
|
+
] = None,
|
|
765
|
+
force: Annotated[
|
|
766
|
+
bool,
|
|
767
|
+
typer.Option(
|
|
768
|
+
"--force", help="Forçar reconversão mesmo se sha256 coincidir"
|
|
769
|
+
),
|
|
770
|
+
] = False,
|
|
630
771
|
verbose: Annotated[
|
|
631
772
|
bool, typer.Option("--verbose", help="Logs detalhados")
|
|
632
773
|
] = False,
|
|
633
774
|
) -> None:
|
|
634
775
|
setup_rich_logging(verbose, console=console)
|
|
635
776
|
try:
|
|
636
|
-
|
|
777
|
+
result = self._run_convert(
|
|
778
|
+
func, input, lake, dataset=dataset, force=force
|
|
779
|
+
)
|
|
637
780
|
except ImportError:
|
|
638
781
|
console.print(
|
|
639
782
|
f"[red]Erro:[/red] convert requer extras de análise: "
|
|
640
783
|
f"{self._analysis_hint()}"
|
|
641
784
|
)
|
|
642
785
|
raise typer.Exit(1) from None
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
786
|
+
failed = self._report_convert(result, console)
|
|
787
|
+
if failed:
|
|
788
|
+
console.print("[red]✗[/red] Conversão concluída com erros.")
|
|
789
|
+
raise typer.Exit(1)
|
|
790
|
+
console.print(f"[green]✓[/green] Conversão concluída em [dim]{lake}[/dim].")
|
|
646
791
|
|
|
647
792
|
return func
|
|
648
793
|
|
|
@@ -697,26 +842,27 @@ class FetcherApp:
|
|
|
697
842
|
|
|
698
843
|
return func
|
|
699
844
|
|
|
700
|
-
def command_pipeline(
|
|
701
|
-
self, convert_func: Callable[[Path, Path], Any]
|
|
702
|
-
) -> Callable[[Path, Path], Any]:
|
|
845
|
+
def command_pipeline(self, convert_func: Callable[..., Any]) -> Callable[..., Any]:
|
|
703
846
|
"""Register the standard ``pipeline`` subcommand (sync → convert).
|
|
704
847
|
|
|
705
848
|
Step 1/2 invokes the built-in ``sync`` command (same flags: groups,
|
|
706
|
-
``-o/--output``, ``--
|
|
707
|
-
|
|
849
|
+
``-o/--output``, ``--workers``, ``--dry-run``, ``--verbose``) and step
|
|
850
|
+
2/2 invokes ``convert_func(output, lake)`` as in :meth:`command_convert`
|
|
851
|
+
(``--lake`` defaults to ``lake_root()``; ``--parquet-dir`` is kept as an
|
|
852
|
+
alias for backwards compatibility).
|
|
708
853
|
With ``--dry-run`` the plan is only listed and the conversion step is
|
|
709
854
|
skipped. An ``ImportError`` from ``convert_func`` is treated as
|
|
710
855
|
missing analytical extras.
|
|
711
856
|
|
|
712
857
|
Args:
|
|
713
|
-
convert_func: Callable receiving ``(output,
|
|
714
|
-
|
|
858
|
+
convert_func: Callable receiving ``(output, lake)`` paths and
|
|
859
|
+
performing the conversion.
|
|
715
860
|
|
|
716
861
|
Returns:
|
|
717
862
|
The original ``convert_func`` (usable as a decorator).
|
|
718
863
|
"""
|
|
719
864
|
console = get_console()
|
|
865
|
+
default_lake = self._default_lake()
|
|
720
866
|
|
|
721
867
|
@self.app.command("pipeline")
|
|
722
868
|
def pipeline(
|
|
@@ -732,11 +878,12 @@ class FetcherApp:
|
|
|
732
878
|
Path | None,
|
|
733
879
|
typer.Option("-o", "--output", help="Diretório de saída"),
|
|
734
880
|
] = None,
|
|
735
|
-
|
|
881
|
+
lake: Annotated[
|
|
736
882
|
Path | None,
|
|
737
883
|
typer.Option(
|
|
884
|
+
"--lake",
|
|
738
885
|
"--parquet-dir",
|
|
739
|
-
help="
|
|
886
|
+
help="Raiz do lake de destino (padrão: QUANTILICA_LAKE_DIR)",
|
|
740
887
|
),
|
|
741
888
|
] = None,
|
|
742
889
|
workers: Annotated[
|
|
@@ -751,7 +898,7 @@ class FetcherApp:
|
|
|
751
898
|
) -> None:
|
|
752
899
|
setup_rich_logging(verbose, console=console)
|
|
753
900
|
actual_output = output or self.default_output
|
|
754
|
-
|
|
901
|
+
lake_out = lake or default_lake
|
|
755
902
|
|
|
756
903
|
console.print(Rule("[bold]Passo 1/2: Download[/bold]"))
|
|
757
904
|
sync_cmd = next(
|
|
@@ -785,16 +932,18 @@ class FetcherApp:
|
|
|
785
932
|
|
|
786
933
|
console.print(Rule("[bold]Passo 2/2: Conversão[/bold]"))
|
|
787
934
|
try:
|
|
788
|
-
convert_func
|
|
935
|
+
result = self._run_convert(convert_func, actual_output, lake_out)
|
|
789
936
|
except ImportError:
|
|
790
937
|
console.print(
|
|
791
938
|
f"[red]Erro:[/red] pipeline (conversão) requer extras de "
|
|
792
939
|
f"análise: {self._analysis_hint()}"
|
|
793
940
|
)
|
|
794
941
|
raise typer.Exit(1) from None
|
|
942
|
+
if self._report_convert(result, console):
|
|
943
|
+
console.print("[red]✗[/red] Pipeline concluído com erros na conversão.")
|
|
944
|
+
raise typer.Exit(1)
|
|
795
945
|
console.print(
|
|
796
|
-
f"[green]✓[/green] Pipeline concluído:
|
|
797
|
-
f"[dim]{parquet_out}[/dim]."
|
|
946
|
+
f"[green]✓[/green] Pipeline concluído: lake em [dim]{lake_out}[/dim]."
|
|
798
947
|
)
|
|
799
948
|
|
|
800
949
|
return convert_func
|
|
@@ -819,7 +968,7 @@ class FetcherApp:
|
|
|
819
968
|
return self.client.download_with_manifest(
|
|
820
969
|
url,
|
|
821
970
|
output,
|
|
822
|
-
source_id=self.
|
|
971
|
+
source_id=self.source_id,
|
|
823
972
|
dataset_id=dataset_id,
|
|
824
973
|
producer=self.name,
|
|
825
974
|
progress=progress,
|
|
@@ -1146,24 +1295,41 @@ class FetcherApp:
|
|
|
1146
1295
|
stream = getattr(self.client, "stream", None)
|
|
1147
1296
|
if not callable(stream):
|
|
1148
1297
|
return None
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1298
|
+
for attempt in range(1, _PROBE_ATTEMPTS + 1):
|
|
1299
|
+
try:
|
|
1300
|
+
with stream(
|
|
1301
|
+
"GET",
|
|
1302
|
+
url,
|
|
1303
|
+
# identity: Content-Length/Content-Range devem refletir o
|
|
1304
|
+
# tamanho real, não o comprimido.
|
|
1305
|
+
headers={"Range": "bytes=0-0", "Accept-Encoding": "identity"},
|
|
1306
|
+
) as response:
|
|
1307
|
+
headers = response.headers
|
|
1308
|
+
status = response.status_code
|
|
1309
|
+
final_url = str(response.url)
|
|
1310
|
+
break
|
|
1311
|
+
except HttpStatusError as exc:
|
|
1312
|
+
if exc.status_code == 404:
|
|
1313
|
+
raise _ProbeNotFoundError(url) from exc
|
|
1314
|
+
failure: Exception = exc
|
|
1315
|
+
retryable = exc.status_code in _PROBE_RETRY_STATUSES
|
|
1316
|
+
except DEFAULT_RETRY_EXCEPTIONS as exc:
|
|
1317
|
+
failure, retryable = exc, True
|
|
1318
|
+
except Exception as exc: # noqa: BLE001 - probe é best effort
|
|
1319
|
+
failure, retryable = exc, False
|
|
1320
|
+
# 429/5xx ou erro de transporte transitório (ex.: 503 intermitente
|
|
1321
|
+
# do gov.br): sem nova tentativa, uma URL removida viraria
|
|
1322
|
+
# "download" e falharia com 404 no sync.
|
|
1323
|
+
if retryable and attempt < _PROBE_ATTEMPTS:
|
|
1324
|
+
time.sleep(_PROBE_BACKOFF_S * attempt)
|
|
1325
|
+
continue
|
|
1326
|
+
# WARNING: o cron não roda com --verbose e precisa registrar a causa.
|
|
1327
|
+
logger.warning(
|
|
1328
|
+
"[check] probe Range falhou para %s após %d tentativa(s): %r",
|
|
1152
1329
|
url,
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
) as response:
|
|
1157
|
-
headers = response.headers
|
|
1158
|
-
status = response.status_code
|
|
1159
|
-
final_url = str(response.url)
|
|
1160
|
-
except HttpStatusError as exc:
|
|
1161
|
-
if exc.status_code == 404:
|
|
1162
|
-
raise _ProbeNotFoundError(url) from exc
|
|
1163
|
-
logger.debug("[check] probe Range falhou para %s: %r", url, exc)
|
|
1164
|
-
return None
|
|
1165
|
-
except Exception as exc: # noqa: BLE001 - probe é best effort
|
|
1166
|
-
logger.debug("[check] probe Range falhou para %s: %r", url, exc)
|
|
1330
|
+
attempt,
|
|
1331
|
+
failure,
|
|
1332
|
+
)
|
|
1167
1333
|
return None
|
|
1168
1334
|
|
|
1169
1335
|
total: int | None = None
|
|
@@ -1208,6 +1374,45 @@ class FetcherApp:
|
|
|
1208
1374
|
entry=dict(entry),
|
|
1209
1375
|
)
|
|
1210
1376
|
|
|
1377
|
+
def _check_entry_isolated(
|
|
1378
|
+
self, entry: dict[str, Any], output_dir: Path
|
|
1379
|
+
) -> CheckPlanItem:
|
|
1380
|
+
"""Run :meth:`check_entry` without letting one entry abort the check.
|
|
1381
|
+
|
|
1382
|
+
An unexpected error (e.g. ``OSError`` while building the local path)
|
|
1383
|
+
is retried once; if it persists the entry becomes a conservative
|
|
1384
|
+
``download`` verdict, so the plan still covers every other entry
|
|
1385
|
+
instead of the whole ``check`` exiting 1.
|
|
1386
|
+
"""
|
|
1387
|
+
for attempt in (1, 2):
|
|
1388
|
+
try:
|
|
1389
|
+
return self.check_entry(entry, output_dir)
|
|
1390
|
+
except Exception as exc: # noqa: BLE001 - isolate per entry
|
|
1391
|
+
failure = exc
|
|
1392
|
+
if attempt == 1:
|
|
1393
|
+
time.sleep(1.0)
|
|
1394
|
+
eid = str(entry.get("id", "unknown"))
|
|
1395
|
+
logger.warning(
|
|
1396
|
+
"[check] falha ao verificar %s (%s: %s); marcada para download",
|
|
1397
|
+
eid,
|
|
1398
|
+
type(failure).__name__,
|
|
1399
|
+
failure,
|
|
1400
|
+
)
|
|
1401
|
+
return CheckPlanItem(
|
|
1402
|
+
dataset=str(entry.get("group") or entry.get("id") or "datasets"),
|
|
1403
|
+
id=eid,
|
|
1404
|
+
url=str(entry.get("url", "")),
|
|
1405
|
+
partition=_partition_label(entry),
|
|
1406
|
+
local_path="",
|
|
1407
|
+
remote_etag=None,
|
|
1408
|
+
remote_last_modified=None,
|
|
1409
|
+
remote_size=None,
|
|
1410
|
+
local_exists=False,
|
|
1411
|
+
action="download",
|
|
1412
|
+
reason=f"check-error: {type(failure).__name__}: {failure}",
|
|
1413
|
+
entry=dict(entry),
|
|
1414
|
+
)
|
|
1415
|
+
|
|
1211
1416
|
def check_datasets(
|
|
1212
1417
|
self,
|
|
1213
1418
|
entries: list[dict[str, Any]],
|
|
@@ -1227,7 +1432,7 @@ class FetcherApp:
|
|
|
1227
1432
|
results: list[CheckPlanItem | None] = [None] * len(entries)
|
|
1228
1433
|
|
|
1229
1434
|
def _worker(index: int, entry: dict[str, Any]) -> None:
|
|
1230
|
-
results[index] = self.
|
|
1435
|
+
results[index] = self._check_entry_isolated(entry, output_dir)
|
|
1231
1436
|
if (index + 1) % 100 == 0 or index + 1 == len(entries):
|
|
1232
1437
|
logger.info("[check] %d/%d verificados", index + 1, len(entries))
|
|
1233
1438
|
|
|
@@ -24,6 +24,7 @@ app = typer.Typer(
|
|
|
24
24
|
console = Console()
|
|
25
25
|
|
|
26
26
|
FETCHER_GROUP = "quantilica.fetchers"
|
|
27
|
+
LAKE_GROUP = "quantilica.lake"
|
|
27
28
|
DEFAULT_INDEX_URL = "https://index.quantilica.com/simple/"
|
|
28
29
|
|
|
29
30
|
# Mapeamento estático padrão de comandos CLI para nome das distribuições
|
|
@@ -31,15 +32,22 @@ SOURCES_REGISTRY: dict[str, str] = {
|
|
|
31
32
|
"anac": "anac-fetcher",
|
|
32
33
|
"anp": "anp-fetcher",
|
|
33
34
|
"b3": "b3-fetcher",
|
|
35
|
+
"bcb": "bcb-sgs-fetcher",
|
|
34
36
|
"bcb-sgs": "bcb-sgs-fetcher",
|
|
35
37
|
"comex": "comex-fetcher",
|
|
36
38
|
"cvm": "cvm-fetcher",
|
|
37
39
|
"datasus": "datasus-fetcher",
|
|
40
|
+
"inep": "inep-fetcher",
|
|
38
41
|
"inmet": "inmet-fetcher",
|
|
39
42
|
"pdet": "pdet-fetcher",
|
|
43
|
+
"rfb": "rfb-cnpj-fetcher",
|
|
44
|
+
"rfb-cnpj": "rfb-cnpj-fetcher",
|
|
40
45
|
"rtn": "rtn-fetcher",
|
|
41
46
|
"sidra": "sidra-fetcher",
|
|
47
|
+
"sql": "quantilica-sql",
|
|
42
48
|
"td": "tesouro-direto-fetcher",
|
|
49
|
+
"tesouro-direto": "tesouro-direto-fetcher",
|
|
50
|
+
"tesouro_direto": "tesouro-direto-fetcher",
|
|
43
51
|
"tse": "tse-fetcher",
|
|
44
52
|
}
|
|
45
53
|
|
|
@@ -115,6 +123,34 @@ def get_installed_entry_points() -> set[str]:
|
|
|
115
123
|
return {ep.name for ep in eps}
|
|
116
124
|
|
|
117
125
|
|
|
126
|
+
def get_lake_entry_points() -> list[tuple[str, str | None]]:
|
|
127
|
+
"""Lista os conversores do lake (entry points ``quantilica.lake``) instalados.
|
|
128
|
+
|
|
129
|
+
Não carrega os entry points: importar o conversor exige os extras de
|
|
130
|
+
análise, e o ``doctor`` só precisa saber se ele está registrado.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Pares ``(nome do entry point, nome da distribuição ou None)``.
|
|
134
|
+
"""
|
|
135
|
+
return [
|
|
136
|
+
(ep.name, ep.dist.name if ep.dist is not None else None)
|
|
137
|
+
for ep in entry_points(group=LAKE_GROUP)
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def has_lake_converter(
|
|
142
|
+
source: str, lake_eps: list[tuple[str, str | None]], dist: str | None = None
|
|
143
|
+
) -> bool:
|
|
144
|
+
"""Indica se a fonte tem conversor do lake registrado.
|
|
145
|
+
|
|
146
|
+
Casa pelo nome do entry point (``source_id``) ou pela distribuição da fonte.
|
|
147
|
+
"""
|
|
148
|
+
dists = {d for _, d in lake_eps if d}
|
|
149
|
+
return any(name == source for name, _ in lake_eps) or (
|
|
150
|
+
dist is not None and dist in dists
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
118
154
|
def get_index_url() -> str:
|
|
119
155
|
"""Retorna a URL do índice pip customizado ou default.
|
|
120
156
|
|
|
@@ -361,7 +397,10 @@ def cmd_uninstall(
|
|
|
361
397
|
|
|
362
398
|
@app.command("doctor")
|
|
363
399
|
def cmd_doctor() -> None:
|
|
364
|
-
"""Verifica inconsistências entre o estado registrado e os plugins instalados.
|
|
400
|
+
"""Verifica inconsistências entre o estado registrado e os plugins instalados.
|
|
401
|
+
|
|
402
|
+
Também informa, por fonte, se há conversor do lake (``quantilica.lake``).
|
|
403
|
+
"""
|
|
365
404
|
state = load_state()
|
|
366
405
|
registered = state.get("installed", {})
|
|
367
406
|
installed_eps = get_installed_entry_points()
|
|
@@ -375,18 +414,33 @@ def cmd_doctor() -> None:
|
|
|
375
414
|
console.print(
|
|
376
415
|
"[green]✓ Todas as fontes registradas estão instaladas e ativas.[/green]"
|
|
377
416
|
)
|
|
378
|
-
|
|
417
|
+
else:
|
|
418
|
+
console.print(
|
|
419
|
+
f"[yellow]Encontradas {len(missing)} fonte(s) registrada(s) mas ausente(s) no ambiente:[/yellow]"
|
|
420
|
+
)
|
|
421
|
+
for name, dist in missing.items():
|
|
422
|
+
console.print(f" - {name} ({dist})")
|
|
379
423
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
424
|
+
for name, dist in missing.items():
|
|
425
|
+
console.print(f"[cyan]Reinstalando {name} ({dist})...[/cyan]")
|
|
426
|
+
try:
|
|
427
|
+
install_package(dist)
|
|
428
|
+
console.print(f"[green]✓ {name} recuperado.[/green]")
|
|
429
|
+
except Exception as exc:
|
|
430
|
+
console.print(f"[red]Falha ao recuperar {name}:[/red] {exc}")
|
|
431
|
+
|
|
432
|
+
_print_lake_status(installed_eps | set(registered), registered)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _print_lake_status(sources: set[str], registered: dict[str, str]) -> None:
|
|
436
|
+
"""Imprime, por fonte instalada/registrada, se há conversor do lake."""
|
|
437
|
+
if not sources:
|
|
438
|
+
return
|
|
439
|
+
lake_eps = get_lake_entry_points()
|
|
440
|
+
console.print("\n[bold]Lake (conversor quantilica.lake):[/bold]")
|
|
441
|
+
for name in sorted(sources):
|
|
442
|
+
dist = registered.get(name) or SOURCES_REGISTRY.get(name)
|
|
443
|
+
if has_lake_converter(name, lake_eps, dist):
|
|
444
|
+
console.print(f" - {name}: [green]✓[/green] lake: sim")
|
|
445
|
+
else:
|
|
446
|
+
console.print(f" - {name}: [dim]lake: não[/dim]")
|
|
@@ -497,3 +497,225 @@ def test_check_entry_ephemeral_stale_downloads(tmp_path: Path):
|
|
|
497
497
|
item = app.check_entry(entry, tmp_path)
|
|
498
498
|
assert item.action == "download"
|
|
499
499
|
assert item.reason == "remote-newer"
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def test_source_id_defaults_to_name_without_suffix():
|
|
503
|
+
app = make_app()
|
|
504
|
+
assert app.source_id == "comex"
|
|
505
|
+
assert app.default_output == Path("/data/comex")
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def test_explicit_source_id_derives_default_output():
|
|
509
|
+
app = make_app(source_id="custom")
|
|
510
|
+
assert app.source_id == "custom"
|
|
511
|
+
assert app.default_output == Path("/data/custom")
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def test_explicit_default_output_wins_over_source_id(tmp_path: Path):
|
|
515
|
+
app = make_app(source_id="custom", default_output=tmp_path)
|
|
516
|
+
assert app.default_output == tmp_path
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def test_check_datasets_isolates_entry_failure(tmp_path: Path, monkeypatch):
|
|
520
|
+
"""Uma entrada que levanta (ex.: OSError) não derruba o check inteiro."""
|
|
521
|
+
monkeypatch.setattr("quantilica.cli.sdk.time.sleep", lambda _s: None)
|
|
522
|
+
app = make_app(
|
|
523
|
+
client=HttpClient(
|
|
524
|
+
attempts=1,
|
|
525
|
+
transport=httpx2.MockTransport(lambda r: httpx2.Response(200)),
|
|
526
|
+
)
|
|
527
|
+
)
|
|
528
|
+
entries = sample_list_datasets("exp")
|
|
529
|
+
calls: dict[str, int] = {}
|
|
530
|
+
real = app.check_entry
|
|
531
|
+
|
|
532
|
+
def flaky(entry, output_dir):
|
|
533
|
+
key = str(entry["id"])
|
|
534
|
+
calls[key] = calls.get(key, 0) + 1
|
|
535
|
+
if key == str(entries[0]["id"]):
|
|
536
|
+
raise FileExistsError(17, "File exists", "/data")
|
|
537
|
+
return real(entry, output_dir)
|
|
538
|
+
|
|
539
|
+
monkeypatch.setattr(app, "check_entry", flaky)
|
|
540
|
+
plan = app.check_datasets(entries, tmp_path)
|
|
541
|
+
assert len(plan.items) == len(entries)
|
|
542
|
+
bad = next(i for i in plan.items if i.id == str(entries[0]["id"]))
|
|
543
|
+
assert bad.action == "download"
|
|
544
|
+
assert bad.reason.startswith("check-error: FileExistsError")
|
|
545
|
+
assert calls[str(entries[0]["id"])] == 2 # uma nova tentativa
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def test_check_datasets_retry_recovers_transient_failure(tmp_path: Path, monkeypatch):
|
|
549
|
+
monkeypatch.setattr("quantilica.cli.sdk.time.sleep", lambda _s: None)
|
|
550
|
+
app = make_app(
|
|
551
|
+
client=HttpClient(
|
|
552
|
+
attempts=1,
|
|
553
|
+
transport=httpx2.MockTransport(lambda r: httpx2.Response(200)),
|
|
554
|
+
)
|
|
555
|
+
)
|
|
556
|
+
entries = sample_list_datasets("exp")[:1]
|
|
557
|
+
real = app.check_entry
|
|
558
|
+
state = {"n": 0}
|
|
559
|
+
|
|
560
|
+
def once(entry, output_dir):
|
|
561
|
+
state["n"] += 1
|
|
562
|
+
if state["n"] == 1:
|
|
563
|
+
raise OSError("transiente")
|
|
564
|
+
return real(entry, output_dir)
|
|
565
|
+
|
|
566
|
+
monkeypatch.setattr(app, "check_entry", once)
|
|
567
|
+
plan = app.check_datasets(entries, tmp_path)
|
|
568
|
+
assert plan.items[0].reason != "" and not plan.items[0].reason.startswith(
|
|
569
|
+
"check-error"
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def test_check_entry_403_head_without_probe_support_downloads(tmp_path: Path):
|
|
574
|
+
"""HEAD 403 e probe Range 403 → download (nunca exceção/exit 1)."""
|
|
575
|
+
app = make_app(
|
|
576
|
+
client=HttpClient(
|
|
577
|
+
attempts=1,
|
|
578
|
+
transport=httpx2.MockTransport(lambda r: httpx2.Response(403)),
|
|
579
|
+
)
|
|
580
|
+
)
|
|
581
|
+
item = app.check_entry(sample_list_datasets("exp")[0], tmp_path)
|
|
582
|
+
assert item.action == "download"
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
# ---------------------------------------------------------------
|
|
586
|
+
# command_convert / command_pipeline: Lake Hive
|
|
587
|
+
# ---------------------------------------------------------------
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def test_convert_lake_flag_and_output_alias(tmp_path: Path):
|
|
591
|
+
app = make_app()
|
|
592
|
+
calls: list[tuple[Path, Path]] = []
|
|
593
|
+
|
|
594
|
+
@app.command_convert
|
|
595
|
+
def conv(input: Path, lake: Path) -> None:
|
|
596
|
+
calls.append((input, lake))
|
|
597
|
+
|
|
598
|
+
for flag in ("--lake", "-o", "--output"):
|
|
599
|
+
result = runner.invoke(
|
|
600
|
+
_grouped(app.app),
|
|
601
|
+
["fd", "convert", "-i", str(tmp_path), flag, str(tmp_path / "l")],
|
|
602
|
+
)
|
|
603
|
+
assert result.exit_code == 0, result.output
|
|
604
|
+
assert calls == [(tmp_path, tmp_path / "l")] * 3
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def test_convert_passes_dataset_and_force_when_accepted(tmp_path: Path):
|
|
608
|
+
app = make_app()
|
|
609
|
+
seen: dict = {}
|
|
610
|
+
|
|
611
|
+
@app.command_convert
|
|
612
|
+
def conv(input: Path, lake: Path, dataset=None, force=False) -> None:
|
|
613
|
+
seen.update(dataset=dataset, force=force)
|
|
614
|
+
|
|
615
|
+
result = runner.invoke(
|
|
616
|
+
_grouped(app.app),
|
|
617
|
+
["fd", "convert", "--dataset", "a", "--dataset", "b", "--force"],
|
|
618
|
+
)
|
|
619
|
+
assert result.exit_code == 0, result.output
|
|
620
|
+
assert seen == {"dataset": ["a", "b"], "force": True}
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def test_convert_kwargs_func_receives_dataset_and_force():
|
|
624
|
+
app = make_app()
|
|
625
|
+
seen: dict = {}
|
|
626
|
+
|
|
627
|
+
@app.command_convert
|
|
628
|
+
def conv(input: Path, lake: Path, **kwargs) -> None:
|
|
629
|
+
seen.update(kwargs)
|
|
630
|
+
|
|
631
|
+
result = runner.invoke(_grouped(app.app), ["fd", "convert"])
|
|
632
|
+
assert result.exit_code == 0, result.output
|
|
633
|
+
assert seen == {"dataset": None, "force": False}
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def test_convert_legacy_two_arg_func_ignores_unused_defaults():
|
|
637
|
+
app = make_app()
|
|
638
|
+
calls: list[tuple[Path, Path]] = []
|
|
639
|
+
|
|
640
|
+
@app.command_convert
|
|
641
|
+
def conv(input: Path, lake: Path) -> None:
|
|
642
|
+
calls.append((input, lake))
|
|
643
|
+
|
|
644
|
+
result = runner.invoke(_grouped(app.app), ["fd", "convert"])
|
|
645
|
+
assert result.exit_code == 0, result.output
|
|
646
|
+
assert len(calls) == 1
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
@pytest.mark.parametrize("flags", [["--dataset", "x"], ["--force"]])
|
|
650
|
+
def test_convert_legacy_func_rejects_requested_unsupported_flag(flags: list[str]):
|
|
651
|
+
app = make_app()
|
|
652
|
+
|
|
653
|
+
@app.command_convert
|
|
654
|
+
def conv(input: Path, lake: Path) -> None: ...
|
|
655
|
+
|
|
656
|
+
result = runner.invoke(_grouped(app.app), ["fd", "convert", *flags])
|
|
657
|
+
assert result.exit_code == 2
|
|
658
|
+
assert "não suporta" in result.output
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def test_convert_summary_from_object_and_exit_0_without_errors():
|
|
662
|
+
from types import SimpleNamespace
|
|
663
|
+
|
|
664
|
+
app = make_app()
|
|
665
|
+
|
|
666
|
+
@app.command_convert
|
|
667
|
+
def conv(input: Path, lake: Path):
|
|
668
|
+
return SimpleNamespace(converted=3, skipped=2, errors=0)
|
|
669
|
+
|
|
670
|
+
result = runner.invoke(_grouped(app.app), ["fd", "convert"])
|
|
671
|
+
assert result.exit_code == 0, result.output
|
|
672
|
+
assert "convertidos" in result.output
|
|
673
|
+
assert "pulados" in result.output
|
|
674
|
+
assert "Conversão concluída" in result.output
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def test_convert_summary_from_dict_with_errors_exits_1():
|
|
678
|
+
app = make_app()
|
|
679
|
+
|
|
680
|
+
@app.command_convert
|
|
681
|
+
def conv(input: Path, lake: Path):
|
|
682
|
+
return {"converted": ["a"], "skipped": [], "errors": ["boom [x]"]}
|
|
683
|
+
|
|
684
|
+
result = runner.invoke(_grouped(app.app), ["fd", "convert"])
|
|
685
|
+
assert result.exit_code == 1
|
|
686
|
+
assert "boom [x]" in result.output
|
|
687
|
+
assert "com erros" in result.output
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def test_convert_unrelated_return_value_is_ignored():
|
|
691
|
+
app = make_app()
|
|
692
|
+
|
|
693
|
+
@app.command_convert
|
|
694
|
+
def conv(input: Path, lake: Path):
|
|
695
|
+
return 42
|
|
696
|
+
|
|
697
|
+
result = runner.invoke(_grouped(app.app), ["fd", "convert"])
|
|
698
|
+
assert result.exit_code == 0, result.output
|
|
699
|
+
assert "convertidos" not in result.output
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def test_pipeline_lake_flag_and_parquet_dir_alias(
|
|
703
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
704
|
+
):
|
|
705
|
+
app = make_app()
|
|
706
|
+
monkeypatch.setattr(
|
|
707
|
+
app, "download_datasets", lambda entries, output_dir, workers=4: (0, 0, [])
|
|
708
|
+
)
|
|
709
|
+
calls: list[tuple[Path, Path]] = []
|
|
710
|
+
|
|
711
|
+
@app.command_pipeline
|
|
712
|
+
def conv(input: Path, lake: Path) -> None:
|
|
713
|
+
calls.append((input, lake))
|
|
714
|
+
|
|
715
|
+
for flag in ("--lake", "--parquet-dir"):
|
|
716
|
+
result = runner.invoke(
|
|
717
|
+
_grouped(app.app),
|
|
718
|
+
["fd", "pipeline", "exp", "-o", str(tmp_path), flag, str(tmp_path / "l")],
|
|
719
|
+
)
|
|
720
|
+
assert result.exit_code == 0, result.output
|
|
721
|
+
assert calls == [(tmp_path, tmp_path / "l")] * 2
|
|
@@ -485,3 +485,92 @@ def test_head_403_probe_500_stays_download(tmp_path: Path):
|
|
|
485
485
|
item = _probe_app(handler).check_entry(ENTRY, tmp_path)
|
|
486
486
|
assert item.action == "download"
|
|
487
487
|
assert item.reason.startswith("metadata-unavailable")
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
@pytest.mark.parametrize(
|
|
491
|
+
("statuses", "action", "reason"),
|
|
492
|
+
[
|
|
493
|
+
# 503 transitório e depois 404 → removida de fato
|
|
494
|
+
([503, 404], "skip-unavailable", "remote-404"),
|
|
495
|
+
# 429 e depois 206 → metadados pelo Range
|
|
496
|
+
([429, 206], "download", "range-probe"),
|
|
497
|
+
# 503 persistente → desiste após as tentativas
|
|
498
|
+
([503, 503, 503], "download", "metadata-unavailable"),
|
|
499
|
+
# 403 no GET não é transitório → sem nova tentativa
|
|
500
|
+
([403], "download", "metadata-unavailable"),
|
|
501
|
+
],
|
|
502
|
+
)
|
|
503
|
+
def test_probe_retries_transient_status(
|
|
504
|
+
tmp_path: Path, monkeypatch, statuses, action, reason
|
|
505
|
+
):
|
|
506
|
+
from quantilica.cli import sdk
|
|
507
|
+
|
|
508
|
+
monkeypatch.setattr(sdk, "_PROBE_BACKOFF_S", 0.0)
|
|
509
|
+
gets: list[int] = []
|
|
510
|
+
|
|
511
|
+
def handler(request):
|
|
512
|
+
if request.method == "HEAD":
|
|
513
|
+
return httpx2.Response(403)
|
|
514
|
+
status = statuses[min(len(gets), len(statuses) - 1)]
|
|
515
|
+
gets.append(status)
|
|
516
|
+
headers = {"Content-Range": "bytes 0-0/10"} if status == 206 else {}
|
|
517
|
+
return httpx2.Response(status, headers=headers)
|
|
518
|
+
|
|
519
|
+
item = _probe_app(handler).check_entry(ENTRY, tmp_path)
|
|
520
|
+
assert item.action == action
|
|
521
|
+
assert item.reason.startswith(reason)
|
|
522
|
+
assert gets == statuses
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
@pytest.mark.parametrize(
|
|
526
|
+
("failures", "action", "reason"),
|
|
527
|
+
[
|
|
528
|
+
# timeout transitório e depois 404 → removida de fato
|
|
529
|
+
([httpx2.ReadTimeout], "skip-unavailable", "remote-404"),
|
|
530
|
+
# conexão derrubada e depois 206 → metadados pelo Range
|
|
531
|
+
([httpx2.RemoteProtocolError], "download", "range-probe"),
|
|
532
|
+
# erro de transporte persistente → desiste após as tentativas
|
|
533
|
+
([httpx2.ConnectError] * 3, "download", "metadata-unavailable"),
|
|
534
|
+
],
|
|
535
|
+
)
|
|
536
|
+
def test_probe_retries_transport_errors(
|
|
537
|
+
tmp_path: Path, monkeypatch, failures, action, reason
|
|
538
|
+
):
|
|
539
|
+
from quantilica.cli import sdk
|
|
540
|
+
|
|
541
|
+
monkeypatch.setattr(sdk, "_PROBE_BACKOFF_S", 0.0)
|
|
542
|
+
gets: list[int] = []
|
|
543
|
+
|
|
544
|
+
def handler(request):
|
|
545
|
+
if request.method == "HEAD":
|
|
546
|
+
return httpx2.Response(403)
|
|
547
|
+
gets.append(1)
|
|
548
|
+
if len(gets) <= len(failures):
|
|
549
|
+
raise failures[len(gets) - 1]("transitório", request=request)
|
|
550
|
+
if reason == "remote-404":
|
|
551
|
+
return httpx2.Response(404)
|
|
552
|
+
return httpx2.Response(206, headers={"Content-Range": "bytes 0-0/10"})
|
|
553
|
+
|
|
554
|
+
item = _probe_app(handler).check_entry(ENTRY, tmp_path)
|
|
555
|
+
assert item.action == action
|
|
556
|
+
assert item.reason.startswith(reason)
|
|
557
|
+
assert len(gets) == min(len(failures) + 1, 3)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def test_probe_failure_logs_warning_with_cause(tmp_path: Path, monkeypatch):
|
|
561
|
+
from quantilica.cli import sdk
|
|
562
|
+
|
|
563
|
+
monkeypatch.setattr(sdk, "_PROBE_BACKOFF_S", 0.0)
|
|
564
|
+
warnings: list[str] = []
|
|
565
|
+
monkeypatch.setattr(
|
|
566
|
+
sdk.logger, "warning", lambda msg, *args: warnings.append(msg % args)
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
def handler(request):
|
|
570
|
+
return httpx2.Response(403 if request.method == "HEAD" else 503)
|
|
571
|
+
|
|
572
|
+
item = _probe_app(handler).check_entry(ENTRY, tmp_path)
|
|
573
|
+
assert item.reason.startswith("metadata-unavailable")
|
|
574
|
+
assert len(warnings) == 1
|
|
575
|
+
assert "após 3 tentativa(s)" in warnings[0]
|
|
576
|
+
assert "503" in warnings[0]
|
|
@@ -104,7 +104,10 @@ def test_command_convert_success(tmp_path: Path):
|
|
|
104
104
|
assert "✓" in result.output
|
|
105
105
|
|
|
106
106
|
|
|
107
|
-
def
|
|
107
|
+
def test_command_convert_defaults_to_lake_root(
|
|
108
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
109
|
+
):
|
|
110
|
+
monkeypatch.setenv("QUANTILICA_LAKE_DIR", str(tmp_path / "lake"))
|
|
108
111
|
app = make_app()
|
|
109
112
|
calls: list[tuple[Path, Path]] = []
|
|
110
113
|
|
|
@@ -112,10 +115,9 @@ def test_command_convert_defaults_to_default_output(tmp_path: Path):
|
|
|
112
115
|
def conv(input: Path, output: Path) -> None:
|
|
113
116
|
calls.append((input, output))
|
|
114
117
|
|
|
115
|
-
default = app.default_output
|
|
116
118
|
result = runner.invoke(_grouped(app.app), ["fd", "convert"])
|
|
117
119
|
assert result.exit_code == 0, result.output
|
|
118
|
-
assert calls == [(
|
|
120
|
+
assert calls == [(app.default_output, tmp_path / "lake")]
|
|
119
121
|
|
|
120
122
|
|
|
121
123
|
def test_command_convert_graceful_import_error(tmp_path: Path):
|
|
@@ -235,9 +237,10 @@ def test_command_pipeline_graceful_import_error(
|
|
|
235
237
|
assert "--extra analysis" in saida
|
|
236
238
|
|
|
237
239
|
|
|
238
|
-
def
|
|
240
|
+
def test_command_pipeline_lake_defaults_to_lake_root(
|
|
239
241
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
240
242
|
):
|
|
243
|
+
monkeypatch.setenv("QUANTILICA_LAKE_DIR", str(tmp_path / "lake"))
|
|
241
244
|
app = make_app()
|
|
242
245
|
monkeypatch.setattr(
|
|
243
246
|
app, "download_datasets", lambda entries, output_dir, workers=4: (0, 0, [])
|
|
@@ -251,7 +254,7 @@ def test_command_pipeline_parquet_dir_defaults_to_output(
|
|
|
251
254
|
raw = tmp_path / "raw"
|
|
252
255
|
result = runner.invoke(_grouped(app.app), ["fd", "pipeline", "exp", "-o", str(raw)])
|
|
253
256
|
assert result.exit_code == 0, result.output
|
|
254
|
-
assert calls == [(raw,
|
|
257
|
+
assert calls == [(raw, tmp_path / "lake")]
|
|
255
258
|
|
|
256
259
|
|
|
257
260
|
def test_command_pipeline_requires_sync_command():
|
|
@@ -122,6 +122,37 @@ def test_cmd_doctor(temp_config_dir: Path):
|
|
|
122
122
|
mock_install.assert_called_once_with("rtn-fetcher")
|
|
123
123
|
|
|
124
124
|
|
|
125
|
+
def test_cmd_doctor_lists_lake_converters(temp_config_dir: Path):
|
|
126
|
+
from quantilica.cli.cli import app
|
|
127
|
+
|
|
128
|
+
save_state({"installed": {"comex": "comex-fetcher", "rtn": "rtn-fetcher"}})
|
|
129
|
+
with (
|
|
130
|
+
patch(
|
|
131
|
+
"quantilica.cli.sources.get_installed_entry_points",
|
|
132
|
+
return_value={"comex", "rtn"},
|
|
133
|
+
),
|
|
134
|
+
patch(
|
|
135
|
+
"quantilica.cli.sources.get_lake_entry_points",
|
|
136
|
+
return_value=[("comex", "comex-fetcher")],
|
|
137
|
+
),
|
|
138
|
+
):
|
|
139
|
+
result = CliRunner().invoke(app, ["doctor"])
|
|
140
|
+
assert result.exit_code == 0
|
|
141
|
+
assert "quantilica.lake" in result.output
|
|
142
|
+
assert "comex: ✓ lake: sim" in result.output
|
|
143
|
+
assert "rtn: lake: não" in result.output
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_has_lake_converter_matches_by_name_or_dist():
|
|
147
|
+
from quantilica.cli.sources import has_lake_converter
|
|
148
|
+
|
|
149
|
+
eps = [("pdet", "pdet-fetcher")]
|
|
150
|
+
assert has_lake_converter("pdet", eps)
|
|
151
|
+
assert has_lake_converter("outro", eps, "pdet-fetcher")
|
|
152
|
+
assert not has_lake_converter("outro", eps, "x-fetcher")
|
|
153
|
+
assert not has_lake_converter("outro", [])
|
|
154
|
+
|
|
155
|
+
|
|
125
156
|
def test_dist_spec_monta_extras():
|
|
126
157
|
from quantilica.cli.sources import _dist_spec
|
|
127
158
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|