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.
Files changed (29) hide show
  1. quantilica_cli-0.13.0/.github/workflows/public-hygiene.yml +104 -0
  2. {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/CHANGELOG.md +29 -5
  3. {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/PKG-INFO +1 -1
  4. {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/pyproject.toml +1 -1
  5. {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/src/quantilica/cli/sdk.py +257 -52
  6. {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/src/quantilica/cli/sources.py +69 -15
  7. {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/tests/test_sdk.py +222 -0
  8. {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/tests/test_sdk_force_probe.py +89 -0
  9. {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/tests/test_sdk_onda_a2.py +8 -5
  10. {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/tests/test_sources.py +31 -0
  11. {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/.githooks/pre-commit +0 -0
  12. {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/.githooks/pre-push +0 -0
  13. {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/.github/workflows/publish.yml +0 -0
  14. {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/.github/workflows/test.yml +0 -0
  15. {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/.gitignore +0 -0
  16. {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/LICENSE +0 -0
  17. {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/README.md +0 -0
  18. {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/src/quantilica/cli/__init__.py +0 -0
  19. {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/src/quantilica/cli/cli.py +0 -0
  20. {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/src/quantilica/cli/health.py +0 -0
  21. {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/src/quantilica/cli/manifests.py +0 -0
  22. {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/src/quantilica/cli/ui.py +0 -0
  23. {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/src/quantilica/py.typed +0 -0
  24. {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/tests/__init__.py +0 -0
  25. {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/tests/test_health.py +0 -0
  26. {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/tests/test_manifests.py +0 -0
  27. {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/tests/test_package_hygiene.py +0 -0
  28. {quantilica_cli-0.11.0 → quantilica_cli-0.13.0}/tests/test_sdk_mes_string.py +0 -0
  29. {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 de ingestão efêmera sem forçar
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 (Onda A.2 do SDK).
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
- Onda A.2 do plano `2026-10-03-padronizacao-core-e-consolidacao-fetchers` —
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
- Onda 2 — extensões do SDK (`quantilica.cli.sdk`) para eliminação de
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 no ADR de distribuição de 2026-08-22).
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: quantilica-cli
3
- Version: 0.11.0
3
+ Version: 0.13.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "quantilica-cli"
3
- version = "0.11.0"
3
+ version = "0.13.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" }]
@@ -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
- self.default_output = default_output or Path(
543
- f"/data/{name.replace('-fetcher', '')}"
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 command_convert(
598
- self, func: Callable[[Path, Path], Any]
599
- ) -> Callable[[Path, Path], Any]:
600
- """Register the standard ``convert`` subcommand.
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
- The generated command exposes the canonical flags (``-i/--input``,
603
- ``-o/--output``, ``--verbose``) and delegates to ``func(input, output)``.
604
- An ``ImportError`` propagating from ``func`` is treated as missing
605
- analytical extras and exits gracefully with code 1.
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 receiving ``(input, output)`` paths and performing
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
- output: Annotated[
750
+ lake: Annotated[
627
751
  Path,
628
- typer.Option("-o", "--output", help="Diretório de destino"),
629
- ] = self.default_output,
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
- func(input, output)
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
- console.print(
644
- f"[green]✓[/green] Conversão concluída em [dim]{output}[/dim]."
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``, ``--parquet-dir``, ``--workers``, ``--dry-run``,
707
- ``--verbose``). Step 2/2 invokes ``convert_func(output, parquet_dir)``.
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, parquet_dir)`` paths
714
- and performing the conversion.
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
- parquet_dir: Annotated[
881
+ lake: Annotated[
736
882
  Path | None,
737
883
  typer.Option(
884
+ "--lake",
738
885
  "--parquet-dir",
739
- help="Diretório para os Parquet (padrão: igual a --output)",
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
- parquet_out = parquet_dir or actual_output
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(actual_output, parquet_out)
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: Parquet em "
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.name.replace("-fetcher", ""),
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
- try:
1150
- with stream(
1151
- "GET",
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
- # identity: Content-Length/Content-Range devem refletir o
1154
- # tamanho real, não o comprimido.
1155
- headers={"Range": "bytes=0-0", "Accept-Encoding": "identity"},
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.check_entry(entry, output_dir)
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
- return
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
- console.print(
381
- f"[yellow]Encontradas {len(missing)} fonte(s) registrada(s) mas ausente(s) no ambiente:[/yellow]"
382
- )
383
- for name, dist in missing.items():
384
- console.print(f" - {name} ({dist})")
385
-
386
- for name, dist in missing.items():
387
- console.print(f"[cyan]Reinstalando {name} ({dist})...[/cyan]")
388
- try:
389
- install_package(dist)
390
- console.print(f"[green]✓ {name} recuperado.[/green]")
391
- except Exception as exc:
392
- console.print(f"[red]Falha ao recuperar {name}:[/red] {exc}")
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 test_command_convert_defaults_to_default_output(tmp_path: Path):
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 == [(default, default)]
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 test_command_pipeline_parquet_dir_defaults_to_output(
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, 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