quantilica-cli 0.12.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.12.0 → quantilica_cli-0.13.0}/CHANGELOG.md +18 -5
  3. {quantilica_cli-0.12.0 → quantilica_cli-0.13.0}/PKG-INFO +1 -1
  4. {quantilica_cli-0.12.0 → quantilica_cli-0.13.0}/pyproject.toml +1 -1
  5. {quantilica_cli-0.12.0 → quantilica_cli-0.13.0}/src/quantilica/cli/sdk.py +165 -30
  6. {quantilica_cli-0.12.0 → quantilica_cli-0.13.0}/src/quantilica/cli/sources.py +69 -15
  7. {quantilica_cli-0.12.0 → quantilica_cli-0.13.0}/tests/test_sdk.py +139 -0
  8. {quantilica_cli-0.12.0 → quantilica_cli-0.13.0}/tests/test_sdk_onda_a2.py +8 -5
  9. {quantilica_cli-0.12.0 → quantilica_cli-0.13.0}/tests/test_sources.py +31 -0
  10. {quantilica_cli-0.12.0 → quantilica_cli-0.13.0}/.githooks/pre-commit +0 -0
  11. {quantilica_cli-0.12.0 → quantilica_cli-0.13.0}/.githooks/pre-push +0 -0
  12. {quantilica_cli-0.12.0 → quantilica_cli-0.13.0}/.github/workflows/publish.yml +0 -0
  13. {quantilica_cli-0.12.0 → quantilica_cli-0.13.0}/.github/workflows/test.yml +0 -0
  14. {quantilica_cli-0.12.0 → quantilica_cli-0.13.0}/.gitignore +0 -0
  15. {quantilica_cli-0.12.0 → quantilica_cli-0.13.0}/LICENSE +0 -0
  16. {quantilica_cli-0.12.0 → quantilica_cli-0.13.0}/README.md +0 -0
  17. {quantilica_cli-0.12.0 → quantilica_cli-0.13.0}/src/quantilica/cli/__init__.py +0 -0
  18. {quantilica_cli-0.12.0 → quantilica_cli-0.13.0}/src/quantilica/cli/cli.py +0 -0
  19. {quantilica_cli-0.12.0 → quantilica_cli-0.13.0}/src/quantilica/cli/health.py +0 -0
  20. {quantilica_cli-0.12.0 → quantilica_cli-0.13.0}/src/quantilica/cli/manifests.py +0 -0
  21. {quantilica_cli-0.12.0 → quantilica_cli-0.13.0}/src/quantilica/cli/ui.py +0 -0
  22. {quantilica_cli-0.12.0 → quantilica_cli-0.13.0}/src/quantilica/py.typed +0 -0
  23. {quantilica_cli-0.12.0 → quantilica_cli-0.13.0}/tests/__init__.py +0 -0
  24. {quantilica_cli-0.12.0 → quantilica_cli-0.13.0}/tests/test_health.py +0 -0
  25. {quantilica_cli-0.12.0 → quantilica_cli-0.13.0}/tests/test_manifests.py +0 -0
  26. {quantilica_cli-0.12.0 → quantilica_cli-0.13.0}/tests/test_package_hygiene.py +0 -0
  27. {quantilica_cli-0.12.0 → quantilica_cli-0.13.0}/tests/test_sdk_force_probe.py +0 -0
  28. {quantilica_cli-0.12.0 → quantilica_cli-0.13.0}/tests/test_sdk_mes_string.py +0 -0
  29. {quantilica_cli-0.12.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,19 @@ 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
+
8
21
  ## [0.12.0] - 2026-10-19
9
22
 
10
23
  ### Adicionado
@@ -46,7 +59,7 @@ e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).
46
59
  ### Corrigido
47
60
 
48
61
  - `sdk`: `check_entry` considera `local.exists() or sidecar.exists()`, preservando
49
- 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
50
63
  re-download redundante quando o dado bruto já foi espelhado.
51
64
 
52
65
  ## [0.10.0] - 2026-10-07
@@ -71,7 +84,7 @@ e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).
71
84
 
72
85
  ## [0.9.1] - 2026-10-04
73
86
 
74
- 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).
75
88
 
76
89
  ### Corrigido
77
90
  - `SyntaxWarning: invalid escape sequence '\['` nas mensagens de `convert`,
@@ -95,7 +108,7 @@ fetcher (wheel no GitHub Releases, entrada no índice PEP 503 do ecossistema).
95
108
 
96
109
  ## [0.8.0] - 2026-10-03
97
110
 
98
- Onda A.2 do plano `2026-10-03-padronizacao-core-e-consolidacao-fetchers` —
111
+ Padronização do SDK e consolidação dos fetchers —
99
112
  decorators de ciclo de vida no SDK (`quantilica.cli.sdk`) e abstração de
100
113
  pré-visualização tabular de sincronização.
101
114
 
@@ -126,7 +139,7 @@ pré-visualização tabular de sincronização.
126
139
 
127
140
  ## [0.7.0] - 2026-10-02
128
141
 
129
- 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
130
143
  boilerplate nos fetchers (decisão
131
144
  `2026-10-02-padronizacao-e-deduplicacao-fetchers`).
132
145
 
@@ -160,7 +173,7 @@ boilerplate nos fetchers (decisão
160
173
  ## [0.3.1] - 2026-08-22
161
174
 
162
175
  ### Corrigido
163
- - `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).
164
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`).
165
178
 
166
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.12.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.12.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,6 +9,7 @@ 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
@@ -39,6 +40,7 @@ from quantilica.core.storage import (
39
40
  )
40
41
  from rich.console import Console, Group
41
42
  from rich.live import Live
43
+ from rich.markup import escape
42
44
  from rich.rule import Rule
43
45
  from rich.table import Table
44
46
 
@@ -608,24 +610,132 @@ class FetcherApp:
608
610
  pass
609
611
  return f"quantilica install {key} --extra analysis"
610
612
 
611
- def command_convert(
612
- self, func: Callable[[Path, Path], Any]
613
- ) -> Callable[[Path, Path], Any]:
614
- """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.
615
661
 
616
- The generated command exposes the canonical flags (``-i/--input``,
617
- ``-o/--output``, ``--verbose``) and delegates to ``func(input, output)``.
618
- An ``ImportError`` propagating from ``func`` is treated as missing
619
- 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.
620
730
 
621
731
  Args:
622
- func: Callable receiving ``(input, output)`` paths and performing
623
- the conversion.
732
+ func: Callable performing the conversion.
624
733
 
625
734
  Returns:
626
735
  The original ``func`` (the method can be used as a decorator).
627
736
  """
628
737
  console = get_console()
738
+ default_lake = self._default_lake()
629
739
 
630
740
  @self.app.command("convert")
631
741
  def convert(
@@ -637,26 +747,47 @@ class FetcherApp:
637
747
  help="Diretório de origem com arquivos brutos",
638
748
  ),
639
749
  ] = self.default_output,
640
- output: Annotated[
750
+ lake: Annotated[
641
751
  Path,
642
- typer.Option("-o", "--output", help="Diretório de destino"),
643
- ] = 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,
644
771
  verbose: Annotated[
645
772
  bool, typer.Option("--verbose", help="Logs detalhados")
646
773
  ] = False,
647
774
  ) -> None:
648
775
  setup_rich_logging(verbose, console=console)
649
776
  try:
650
- func(input, output)
777
+ result = self._run_convert(
778
+ func, input, lake, dataset=dataset, force=force
779
+ )
651
780
  except ImportError:
652
781
  console.print(
653
782
  f"[red]Erro:[/red] convert requer extras de análise: "
654
783
  f"{self._analysis_hint()}"
655
784
  )
656
785
  raise typer.Exit(1) from None
657
- console.print(
658
- f"[green]✓[/green] Conversão concluída em [dim]{output}[/dim]."
659
- )
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].")
660
791
 
661
792
  return func
662
793
 
@@ -711,26 +842,27 @@ class FetcherApp:
711
842
 
712
843
  return func
713
844
 
714
- def command_pipeline(
715
- self, convert_func: Callable[[Path, Path], Any]
716
- ) -> Callable[[Path, Path], Any]:
845
+ def command_pipeline(self, convert_func: Callable[..., Any]) -> Callable[..., Any]:
717
846
  """Register the standard ``pipeline`` subcommand (sync → convert).
718
847
 
719
848
  Step 1/2 invokes the built-in ``sync`` command (same flags: groups,
720
- ``-o/--output``, ``--parquet-dir``, ``--workers``, ``--dry-run``,
721
- ``--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).
722
853
  With ``--dry-run`` the plan is only listed and the conversion step is
723
854
  skipped. An ``ImportError`` from ``convert_func`` is treated as
724
855
  missing analytical extras.
725
856
 
726
857
  Args:
727
- convert_func: Callable receiving ``(output, parquet_dir)`` paths
728
- and performing the conversion.
858
+ convert_func: Callable receiving ``(output, lake)`` paths and
859
+ performing the conversion.
729
860
 
730
861
  Returns:
731
862
  The original ``convert_func`` (usable as a decorator).
732
863
  """
733
864
  console = get_console()
865
+ default_lake = self._default_lake()
734
866
 
735
867
  @self.app.command("pipeline")
736
868
  def pipeline(
@@ -746,11 +878,12 @@ class FetcherApp:
746
878
  Path | None,
747
879
  typer.Option("-o", "--output", help="Diretório de saída"),
748
880
  ] = None,
749
- parquet_dir: Annotated[
881
+ lake: Annotated[
750
882
  Path | None,
751
883
  typer.Option(
884
+ "--lake",
752
885
  "--parquet-dir",
753
- help="Diretório para os Parquet (padrão: igual a --output)",
886
+ help="Raiz do lake de destino (padrão: QUANTILICA_LAKE_DIR)",
754
887
  ),
755
888
  ] = None,
756
889
  workers: Annotated[
@@ -765,7 +898,7 @@ class FetcherApp:
765
898
  ) -> None:
766
899
  setup_rich_logging(verbose, console=console)
767
900
  actual_output = output or self.default_output
768
- parquet_out = parquet_dir or actual_output
901
+ lake_out = lake or default_lake
769
902
 
770
903
  console.print(Rule("[bold]Passo 1/2: Download[/bold]"))
771
904
  sync_cmd = next(
@@ -799,16 +932,18 @@ class FetcherApp:
799
932
 
800
933
  console.print(Rule("[bold]Passo 2/2: Conversão[/bold]"))
801
934
  try:
802
- convert_func(actual_output, parquet_out)
935
+ result = self._run_convert(convert_func, actual_output, lake_out)
803
936
  except ImportError:
804
937
  console.print(
805
938
  f"[red]Erro:[/red] pipeline (conversão) requer extras de "
806
939
  f"análise: {self._analysis_hint()}"
807
940
  )
808
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)
809
945
  console.print(
810
- f"[green]✓[/green] Pipeline concluído: Parquet em "
811
- f"[dim]{parquet_out}[/dim]."
946
+ f"[green]✓[/green] Pipeline concluído: lake em [dim]{lake_out}[/dim]."
812
947
  )
813
948
 
814
949
  return convert_func
@@ -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]")
@@ -580,3 +580,142 @@ def test_check_entry_403_head_without_probe_support_downloads(tmp_path: Path):
580
580
  )
581
581
  item = app.check_entry(sample_list_datasets("exp")[0], tmp_path)
582
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
@@ -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