quantilica-cli 0.3.2__tar.gz → 0.7.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 (24) hide show
  1. quantilica_cli-0.7.0/.githooks/pre-push +30 -0
  2. quantilica_cli-0.7.0/.github/workflows/test.yml +55 -0
  3. {quantilica_cli-0.3.2 → quantilica_cli-0.7.0}/CHANGELOG.md +28 -0
  4. {quantilica_cli-0.3.2 → quantilica_cli-0.7.0}/PKG-INFO +2 -2
  5. {quantilica_cli-0.3.2 → quantilica_cli-0.7.0}/pyproject.toml +2 -2
  6. {quantilica_cli-0.3.2 → quantilica_cli-0.7.0}/src/quantilica/cli/sdk.py +198 -23
  7. quantilica_cli-0.7.0/tests/test_sdk.py +277 -0
  8. quantilica_cli-0.3.2/.github/workflows/test.yml +0 -38
  9. {quantilica_cli-0.3.2 → quantilica_cli-0.7.0}/.githooks/pre-commit +0 -0
  10. {quantilica_cli-0.3.2 → quantilica_cli-0.7.0}/.github/workflows/publish.yml +0 -0
  11. {quantilica_cli-0.3.2 → quantilica_cli-0.7.0}/.gitignore +0 -0
  12. {quantilica_cli-0.3.2 → quantilica_cli-0.7.0}/LICENSE +0 -0
  13. {quantilica_cli-0.3.2 → quantilica_cli-0.7.0}/README.md +0 -0
  14. {quantilica_cli-0.3.2 → quantilica_cli-0.7.0}/src/quantilica/cli/__init__.py +0 -0
  15. {quantilica_cli-0.3.2 → quantilica_cli-0.7.0}/src/quantilica/cli/cli.py +0 -0
  16. {quantilica_cli-0.3.2 → quantilica_cli-0.7.0}/src/quantilica/cli/manifests.py +0 -0
  17. {quantilica_cli-0.3.2 → quantilica_cli-0.7.0}/src/quantilica/cli/progress.py +0 -0
  18. {quantilica_cli-0.3.2 → quantilica_cli-0.7.0}/src/quantilica/cli/sources.py +0 -0
  19. {quantilica_cli-0.3.2 → quantilica_cli-0.7.0}/src/quantilica/cli/ui.py +0 -0
  20. {quantilica_cli-0.3.2 → quantilica_cli-0.7.0}/src/quantilica/py.typed +0 -0
  21. {quantilica_cli-0.3.2 → quantilica_cli-0.7.0}/tests/__init__.py +0 -0
  22. {quantilica_cli-0.3.2 → quantilica_cli-0.7.0}/tests/test_manifests.py +0 -0
  23. {quantilica_cli-0.3.2 → quantilica_cli-0.7.0}/tests/test_sources.py +0 -0
  24. {quantilica_cli-0.3.2 → quantilica_cli-0.7.0}/tests/test_ui.py +0 -0
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env bash
2
+ # Barra o push se a árvore inteira não passar em lint/format.
3
+ #
4
+ # Complementa o pre-commit (que só vê .py staged): pega dívida de lint
5
+ # pré-existente e commits que não tocam Python — foi assim que E501 dos
6
+ # sweeps de 2026-08-14 ficou vermelho no CI por semanas e um commit de
7
+ # pyproject re-ativou CI vermelho no inmet (2026-08-31).
8
+ #
9
+ # Instalação (uma vez por clone): git config core.hooksPath .githooks
10
+ # (o bootstrap.sh faz isso automaticamente).
11
+ set -euo pipefail
12
+
13
+ cd "$(git rev-parse --show-toplevel)"
14
+
15
+ [ -d src ] || exit 0
16
+ if [ -d tests ]; then
17
+ TARGETS="src/ tests/"
18
+ else
19
+ TARGETS="src/"
20
+ fi
21
+
22
+ if ! uv run --no-sync ruff --version >/dev/null 2>&1; then
23
+ echo "pre-push: ruff indisponível no ambiente — rodando uv sync --group dev" >&2
24
+ uv sync --group dev
25
+ fi
26
+
27
+ uv run --no-sync ruff check ${TARGETS}
28
+ uv run --no-sync ruff format --check ${TARGETS}
29
+
30
+ echo "pre-push: lint/format da árvore OK."
@@ -0,0 +1,55 @@
1
+ name: Test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+ workflow_dispatch:
9
+
10
+ jobs:
11
+ test:
12
+ name: Test (Python ${{ matrix.python-version }})
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ fail-fast: false
16
+ matrix:
17
+ python-version: ["3.12", "3.13"]
18
+
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+
22
+ - name: Install uv
23
+ uses: astral-sh/setup-uv@v5
24
+ with:
25
+ enable-cache: true
26
+
27
+ - name: Set up Python ${{ matrix.python-version }}
28
+ run: uv python install ${{ matrix.python-version }}
29
+
30
+ # O índice Quantilica (GitHub Pages) já deu flake de DNS no runner
31
+ # (2026-08-23: rtn e inmet). Três tentativas removem o flake sem
32
+ # esconder erro real de resolução.
33
+ - name: Install dependencies
34
+ run: |
35
+ for i in 1 2 3; do
36
+ if uv sync --group dev --python ${{ matrix.python-version }} \
37
+ --index https://index.quantilica.com/simple/ \
38
+ --index-strategy unsafe-best-match; then
39
+ exit 0
40
+ fi
41
+ echo "::warning::uv sync falhou (tentativa $i/3); nova tentativa em 10s"
42
+ sleep 10
43
+ done
44
+ exit 1
45
+
46
+ # Cada `uv run` sem --no-sync re-resolve o lockfile virtual e REMOVE os
47
+ # extras instalados no passo de sync acima (polars/openpyxl já caíram
48
+ # assim: anp 2026-08-29). Todos os passos usam --no-sync.
49
+ - name: Lint with ruff
50
+ run: |
51
+ uv run --no-sync ruff check src/ tests/
52
+ uv run --no-sync ruff format --check src/ tests/
53
+
54
+ - name: Run tests
55
+ run: uv run --no-sync pytest
@@ -5,6 +5,34 @@ 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.7.0] - 2026-10-02
9
+
10
+ Onda 2 — extensões do SDK (`quantilica.cli.sdk`) para eliminação de
11
+ boilerplate nos fetchers (decisão
12
+ `2026-10-02-padronizacao-e-deduplicacao-fetchers`).
13
+
14
+ ### Adicionado
15
+ - `DataRepository` canônico no SDK (baseado em `StampedDataRepository` do core)
16
+ com `path_for_entry(entry, last_modified=...)`, estabelecendo a convenção
17
+ única de layout: `{dataset_id}/{slug}[@{partition}]@{YYYYMMDD}.{ext}`.
18
+ - `default_path_builder(output_dir, entry, last_modified)`: path builder
19
+ canônico para fetchers que não fornecem um próprio.
20
+ - `FetcherApp.attach_command(cmd_func, name=None, **kwargs)`: registro limpo de
21
+ subcomandos customizados (`convert`, `pipeline`, `archive`) sem subclassificar
22
+ e sobrescrever `_build_commands`.
23
+ - `FetcherApp(build_default_commands=False)`: instancie o app sem os comandos
24
+ padrão `sync`/`list` (fim do padrão `def _build_commands(): pass`).
25
+ - `make_resolve_groups(groups_dict, aliases_dict)`: helper que constrói
26
+ resolver de grupos/aliases para comandos customizados (dedup, ordem
27
+ declarada, erro em grupos desconhecidos); o comando `sync` padrão agora o
28
+ usa.
29
+ - Commit `feat(sdk)`: `default_client()` já com `emulate_browser` e pooling
30
+ keep-alive por worker em `download_datasets` (anteriomente não documentado).
31
+
32
+ ### Alterado
33
+ - `path_builder` no `FetcherApp` é agora opcional (default:
34
+ `default_path_builder`).
35
+
8
36
  ## [0.3.2] - 2026-08-22
9
37
 
10
38
  ### Corrigido
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: quantilica-cli
3
- Version: 0.3.2
3
+ Version: 0.7.0
4
4
  Summary: Unified CLI for Quantilica open data fetchers
5
5
  Author-email: "Komesu, D.K." <daniel@dkko.me>
6
6
  License-Expression: MIT
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.12
16
16
  Classifier: Programming Language :: Python :: 3.13
17
17
  Classifier: Typing :: Typed
18
18
  Requires-Python: >=3.12
19
- Requires-Dist: quantilica-core>=0.4.0
19
+ Requires-Dist: quantilica-core>=0.7.0
20
20
  Requires-Dist: rich>=13.0.0
21
21
  Requires-Dist: typer>=0.15.0
22
22
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "quantilica-cli"
3
- version = "0.3.2"
3
+ version = "0.7.0"
4
4
  description = "Unified CLI for Quantilica open data fetchers"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Komesu, D.K.", email = "daniel@dkko.me" }]
@@ -20,7 +20,7 @@ classifiers = [
20
20
  "Programming Language :: Python :: 3.13",
21
21
  ]
22
22
  dependencies = [
23
- "quantilica-core>=0.4.0",
23
+ "quantilica-core>=0.7.0",
24
24
  "rich>=13.0.0",
25
25
  "typer>=0.15.0",
26
26
  ]
@@ -8,7 +8,8 @@ from __future__ import annotations
8
8
  import concurrent.futures
9
9
  import contextlib
10
10
  import datetime as dt
11
- from collections.abc import Callable
11
+ import threading
12
+ from collections.abc import Callable, Iterable
12
13
  from pathlib import Path
13
14
  from typing import Annotated, Any
14
15
 
@@ -17,6 +18,11 @@ from quantilica.core.exceptions import FetchError
17
18
  from quantilica.core.ftp import FtpClient
18
19
  from quantilica.core.http import HttpClient, HttpStatusError, ProgressCallback
19
20
  from quantilica.core.logging import get_logger
21
+ from quantilica.core.storage import (
22
+ StampedDataRepository,
23
+ build_stamped_filename,
24
+ stamp_filename,
25
+ )
20
26
  from rich.console import Group
21
27
  from rich.live import Live
22
28
  from rich.table import Table
@@ -37,23 +43,134 @@ def default_client() -> HttpClient:
37
43
  """Create a default HttpClient with standard configuration.
38
44
 
39
45
  Returns:
40
- A pre-configured HttpClient instance.
46
+ A pre-configured HttpClient instance (browser-like WAF headers + pooling).
41
47
  """
42
48
  return HttpClient(
43
49
  timeout=180.0,
44
50
  verify=True,
45
51
  attempts=5,
46
52
  retry_base_delay=2.0,
47
- headers={
48
- "User-Agent": (
49
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
50
- "AppleWebKit/537.36 (KHTML, like Gecko) "
51
- "Chrome/142.0.0.0 Safari/537.36"
52
- ),
53
- },
53
+ emulate_browser=True,
54
54
  )
55
55
 
56
56
 
57
+ class DataRepository(StampedDataRepository):
58
+ """Canonical data repository layout shared by Quantilica fetchers.
59
+
60
+ Files are stored under ``{dataset_id}/`` directly, with stamped filenames
61
+ following the ecosystem convention ``{slug}[@{partition}]@{YYYYMMDD}.{ext}``
62
+ (see :func:`stamp_filename`). Entries follow the canonical SDK schema:
63
+ ``group``, ``id``, ``ext``, ``url``, and optional ``year``/``month``/
64
+ ``semester`` partition fields.
65
+ """
66
+
67
+ def path_for_entry(
68
+ self,
69
+ entry: dict[str, Any],
70
+ *,
71
+ last_modified: dt.date | None = None,
72
+ ) -> Path:
73
+ """Compute the local path for a dataset entry.
74
+
75
+ Args:
76
+ entry: Dataset entry dictionary (canonical SDK schema).
77
+ last_modified: The last modified date to stamp, or None.
78
+
79
+ Returns:
80
+ Path: The destination path for the entry.
81
+
82
+ Raises:
83
+ StorageError: If a required bucket key is empty or invalid.
84
+ """
85
+ dataset_id = str(entry.get("group") or entry.get("id") or "datasets")
86
+ ext = entry.get("ext")
87
+ if not ext:
88
+ tail = str(entry.get("url", "")).split("?")[0].rsplit("/", 1)[-1]
89
+ ext = tail.rsplit(".", 1)[-1] if "." in tail else "bin"
90
+
91
+ year = entry.get("year")
92
+ month = entry.get("month")
93
+ semester = entry.get("semester")
94
+ slug = str(entry.get("id") or dataset_id)
95
+
96
+ if year is not None and semester is not None:
97
+ filename = build_stamped_filename(
98
+ slug,
99
+ f"{year}-{semester:02d}",
100
+ ext=ext,
101
+ timestamp=last_modified,
102
+ )
103
+ elif year is not None and month is not None:
104
+ filename = build_stamped_filename(
105
+ slug,
106
+ f"{year}-{month:02d}",
107
+ ext=ext,
108
+ timestamp=last_modified,
109
+ )
110
+ elif year is not None:
111
+ filename = build_stamped_filename(
112
+ slug, year, ext=ext, timestamp=last_modified
113
+ )
114
+ else:
115
+ filename = stamp_filename(slug, ext, last_modified)
116
+
117
+ return self.dataset_path(dataset_id, filename)
118
+
119
+
120
+ def default_path_builder(
121
+ output_dir: Path,
122
+ entry: dict[str, Any],
123
+ last_modified: dt.date | None = None,
124
+ ) -> Path:
125
+ """Canonical path builder used when a fetcher does not provide one.
126
+
127
+ Args:
128
+ output_dir: The root output directory.
129
+ entry: The dataset entry dictionary.
130
+ last_modified: The last modified date of the dataset.
131
+
132
+ Returns:
133
+ Path: The destination path for the entry.
134
+ """
135
+ return DataRepository(output_dir).path_for_entry(entry, last_modified=last_modified)
136
+
137
+
138
+ def make_resolve_groups(
139
+ groups_dict: dict[str, dict[str, Any]],
140
+ aliases_dict: dict[str, list[str]],
141
+ ) -> Callable[[Iterable[str] | None], list[str]]:
142
+ """Build a resolver mapping group keys/aliases to canonical group IDs.
143
+
144
+ Args:
145
+ groups_dict: Dictionary of dataset groups and their metadata.
146
+ aliases_dict: Dictionary of alias mappings to dataset groups.
147
+
148
+ Returns:
149
+ Callable: A function receiving group keys and/or aliases (or None for
150
+ all groups) and returning the deduplicated canonical group IDs in
151
+ declaration order. Raises ValueError on unknown keys.
152
+ """
153
+ groups = list(groups_dict)
154
+ aliases = dict(aliases_dict)
155
+
156
+ def resolve(keys: Iterable[str] | None = None) -> list[str]:
157
+ resolved: list[str] = []
158
+ for key in groups if keys is None else keys:
159
+ expanded = (
160
+ list(aliases[key])
161
+ if key in aliases
162
+ else ([key] if key in groups_dict else [])
163
+ )
164
+ if not expanded:
165
+ raise ValueError(f"Grupo desconhecido: {key!r}")
166
+ for canon in expanded:
167
+ if canon not in resolved:
168
+ resolved.append(canon)
169
+ return resolved
170
+
171
+ return resolve
172
+
173
+
57
174
  class FetcherApp:
58
175
  """Standard orchestrator for Quantilica fetchers.
59
176
 
@@ -63,9 +180,14 @@ class FetcherApp:
63
180
  groups_dict: Dictionary of dataset groups and their metadata.
64
181
  aliases_dict: Dictionary of alias mappings to dataset groups.
65
182
  list_datasets: Callback to list datasets given a group ID.
66
- path_builder: Callback to build the destination path.
183
+ path_builder: Callback to build the destination path. If None, uses
184
+ :func:`default_path_builder` (canonical ``DataRepository`` layout).
67
185
  default_output: Default output directory path.
68
186
  client: HTTP or FTP client instance. Defaults to default_client().
187
+ build_default_commands: If True (default), registers the built-in
188
+ ``sync`` and ``list`` commands. Set to False to register only
189
+ custom commands via :meth:`attach_command`.
190
+ client: HTTP or FTP client instance. Defaults to default_client().
69
191
  """
70
192
 
71
193
  def __init__(
@@ -76,16 +198,18 @@ class FetcherApp:
76
198
  groups_dict: dict[str, dict[str, Any]],
77
199
  aliases_dict: dict[str, list[str]],
78
200
  list_datasets: Callable[[str], list[dict[str, Any]]],
79
- path_builder: Callable[[Path, dict[str, Any], dt.date | None], Path],
201
+ path_builder: Callable[[Path, dict[str, Any], dt.date | None], Path]
202
+ | None = None,
80
203
  default_output: Path | None = None,
81
204
  client: HttpClient | FtpClient | None = None,
205
+ build_default_commands: bool = True,
82
206
  ):
83
207
  self.name = name
84
208
  self.help = help
85
209
  self.groups = groups_dict
86
210
  self.aliases = aliases_dict
87
211
  self.list_datasets = list_datasets
88
- self.path_builder = path_builder
212
+ self.path_builder = path_builder or default_path_builder
89
213
  self.default_output = default_output or Path(
90
214
  f"/data/{name.replace('-fetcher', '')}"
91
215
  )
@@ -93,10 +217,31 @@ class FetcherApp:
93
217
 
94
218
  self.all_group_keys = list(self.groups.keys())
95
219
  self.all_keys = self.all_group_keys + list(self.aliases.keys())
220
+ self.resolve_groups = make_resolve_groups(groups_dict, aliases_dict)
96
221
 
97
222
  # O objeto typer principal
98
223
  self.app = typer.Typer(help=help)
99
- self._build_commands()
224
+ if build_default_commands:
225
+ self._build_commands()
226
+
227
+ def attach_command(
228
+ self,
229
+ cmd_func: Callable[..., Any],
230
+ name: str | None = None,
231
+ **kwargs: Any,
232
+ ) -> None:
233
+ """Register a custom subcommand on the app.
234
+
235
+ Lets fetchers add subcommands (e.g. ``convert``, ``pipeline``,
236
+ ``archive``) cleanly, without subclassing and overriding
237
+ ``_build_commands``.
238
+
239
+ Args:
240
+ cmd_func: The Typer command function to register.
241
+ name: The command name. Defaults to the function name.
242
+ **kwargs: Extra options forwarded to ``typer.Typer.command()``.
243
+ """
244
+ self.app.command(name=name, **kwargs)(cmd_func)
100
245
 
101
246
  def _safe_head_date(self, url: str) -> dt.date | None:
102
247
  with contextlib.suppress(Exception):
@@ -208,7 +353,35 @@ class FetcherApp:
208
353
  errors: list[tuple[str, str]] = []
209
354
  pool = ProgressPool(workers=workers, file_prog=file_prog)
210
355
 
356
+ # Pooling por worker: cada thread mantém seu HttpClient com keep-alive.
357
+ thread_local = threading.local()
358
+ _worker_clients: list[HttpClient] = []
359
+
360
+ def _get_worker_client() -> HttpClient | FtpClient:
361
+ if isinstance(self.client, FtpClient):
362
+ return self.client
363
+ if not hasattr(thread_local, "client"):
364
+ # Reusa config do client canônico, mas com sessão persistente.
365
+ c = HttpClient(
366
+ timeout=self.client.timeout,
367
+ headers=dict(self.client.headers),
368
+ follow_redirects=self.client.follow_redirects,
369
+ attempts=self.client.attempts,
370
+ retry_base_delay=self.client.retry_base_delay,
371
+ verify=self.client.verify,
372
+ limits=self.client.limits,
373
+ emulate_browser=True,
374
+ )
375
+ c.__enter__()
376
+ thread_local.client = c
377
+ _worker_clients.append(c)
378
+ return thread_local.client # type: ignore[return-value]
379
+
211
380
  def _worker(entry: dict[str, Any]) -> bool:
381
+ # Troca temporária do client para o da thread (com pooling).
382
+ worker_client = _get_worker_client()
383
+ prev = self.client
384
+ self.client = worker_client # type: ignore[assignment]
212
385
  try:
213
386
  eid = entry.get("id", "unknown")
214
387
  with pool.acquire(description=f"[cyan]{eid}[/cyan]") as cb:
@@ -217,6 +390,8 @@ class FetcherApp:
217
390
  except Exception as exc:
218
391
  errors.append((entry.get("id", "unknown"), str(exc)))
219
392
  return False
393
+ finally:
394
+ self.client = prev
220
395
 
221
396
  with graceful_executor(max_workers=workers) as executor:
222
397
  try:
@@ -235,6 +410,10 @@ class FetcherApp:
235
410
  except KeyboardInterrupt:
236
411
  console.print("\n[yellow]Interrompido.[/yellow]")
237
412
  raise typer.Exit(130) from None
413
+ finally:
414
+ for c in _worker_clients:
415
+ with contextlib.suppress(Exception):
416
+ c.close()
238
417
 
239
418
  return downloaded, total, errors
240
419
 
@@ -265,16 +444,12 @@ class FetcherApp:
265
444
  setup_rich_logging(verbose, console=console)
266
445
  actual_output = output or self.default_output
267
446
 
268
- target_groups: list[str] = []
269
- for g in groups or self.all_group_keys:
270
- expanded = self._expand_group(g)
271
- if not expanded:
272
- console.print(f"[red]Grupo desconhecido: {g!r}[/red]")
273
- console.print(f"Grupos válidos: {', '.join(self.all_keys)}")
274
- raise typer.Exit(1)
275
- for canon in expanded:
276
- if canon not in target_groups:
277
- target_groups.append(canon)
447
+ try:
448
+ target_groups = self.resolve_groups(groups)
449
+ except ValueError as exc:
450
+ console.print(f"[red]{exc}[/red]")
451
+ console.print(f"Grupos válidos: {', '.join(self.all_keys)}")
452
+ raise typer.Exit(1) from None
278
453
 
279
454
  entries = [e for g in target_groups for e in self.list_datasets(g)]
280
455
 
@@ -0,0 +1,277 @@
1
+ """Testes unitários para quantilica.cli.sdk (Onda 2: FetcherApp melhorias)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import pytest
10
+ import typer
11
+ from typer.testing import CliRunner
12
+
13
+ from quantilica.cli.sdk import (
14
+ DataRepository,
15
+ FetcherApp,
16
+ default_path_builder,
17
+ make_resolve_groups,
18
+ )
19
+
20
+ runner = CliRunner()
21
+
22
+
23
+ def _grouped(app: typer.Typer, name: str = "fd") -> typer.Typer:
24
+ """Emita o sub-app como um grupo nomeado.
25
+
26
+ Um `Typer` com apenas um comando é executado de forma plana; agrupar
27
+ garante uma invocação consistente nos testes.
28
+ """
29
+ root = typer.Typer()
30
+ root.add_typer(app, name=name)
31
+ return root
32
+
33
+
34
+ GROUPS = {"exp": {"name": "Exportações"}, "imp": {"name": "Importações"}}
35
+ ALIASES = {"trade": ["exp", "imp"], "empty": []}
36
+
37
+
38
+ def sample_list_datasets(group: str) -> list[dict]:
39
+ return [
40
+ {
41
+ "group": group,
42
+ "id": f"{group}-monthly",
43
+ "ext": "csv",
44
+ "year": 2023,
45
+ "month": 5,
46
+ "url": f"https://example.test/{group}-monthly.csv",
47
+ }
48
+ ]
49
+
50
+
51
+ def make_app(**overrides) -> FetcherApp:
52
+ kwargs: dict = {
53
+ "name": "comex-fetcher",
54
+ "groups_dict": GROUPS,
55
+ "aliases_dict": ALIASES,
56
+ "list_datasets": sample_list_datasets,
57
+ }
58
+ kwargs.update(overrides)
59
+ return FetcherApp(**kwargs) # type: ignore[arg-type]
60
+
61
+
62
+ # ---------------------------------------------------------------
63
+ # default_path_builder / DataRepository
64
+ # ---------------------------------------------------------------
65
+
66
+
67
+ def test_default_path_builder_monthly_partition(tmp_path: Path):
68
+ entry = {
69
+ "group": "exp",
70
+ "id": "exp-mun",
71
+ "ext": "csv",
72
+ "year": 2023,
73
+ "month": 5,
74
+ }
75
+ path = default_path_builder(tmp_path, entry, dt.date(2024, 3, 15))
76
+ assert path == tmp_path / "exp" / "exp-mun_2023-05@20240315.csv"
77
+
78
+
79
+ def test_default_path_builder_semester_partition(tmp_path: Path):
80
+ entry = {
81
+ "group": "exp",
82
+ "id": "exp-sem",
83
+ "ext": "csv",
84
+ "year": 2023,
85
+ "semester": 2,
86
+ }
87
+ path = default_path_builder(tmp_path, entry, dt.date(2024, 3, 15))
88
+ assert path == tmp_path / "exp" / "exp-sem_2023-02@20240315.csv"
89
+
90
+
91
+ def test_default_path_builder_year_only(tmp_path: Path):
92
+ entry = {"group": "exp", "id": "exp", "ext": "csv", "year": 2023}
93
+ path = default_path_builder(tmp_path, entry, dt.date(2024, 3, 15))
94
+ assert path == tmp_path / "exp" / "exp_2023@20240315.csv"
95
+
96
+
97
+ def test_default_path_builder_no_stamp_without_date(tmp_path: Path):
98
+ entry = {"group": "exp", "id": "exp", "ext": "csv", "year": 2023}
99
+ path = default_path_builder(tmp_path, entry, None)
100
+ assert path == tmp_path / "exp" / "exp_2023.csv"
101
+
102
+
103
+ def test_default_path_builder_derives_ext_from_url(tmp_path: Path):
104
+ entry = {"group": "exp", "id": "exp", "url": "https://x.test/file.xlsx?a=b"}
105
+ path = default_path_builder(tmp_path, entry, None)
106
+ assert path.suffix == ".xlsx"
107
+
108
+
109
+ def test_default_path_builder_ext_fallback(tmp_path: Path):
110
+ entry = {"group": "exp", "id": "exp", "url": "https://x.test/download"}
111
+ path = default_path_builder(tmp_path, entry, None)
112
+ assert path.suffix == ".bin"
113
+
114
+
115
+ def test_default_path_builder_no_group_uses_id(tmp_path: Path):
116
+ entry = {"id": "lonely", "ext": "csv"}
117
+ path = default_path_builder(tmp_path, entry, None)
118
+ assert path == tmp_path / "lonely" / "lonely.csv"
119
+
120
+
121
+ def test_data_repository_is_stamped(tmp_path: Path):
122
+ repo = DataRepository(tmp_path)
123
+ entry = {"group": "imp", "id": "imp", "ext": "csv", "year": 2024}
124
+ path = repo.path_for_entry(entry, last_modified=dt.date(2025, 1, 2))
125
+ assert path == tmp_path / "imp" / "imp_2024@20250102.csv"
126
+
127
+
128
+ # ---------------------------------------------------------------
129
+ # FetcherApp: path_builder opcional
130
+ # ---------------------------------------------------------------
131
+
132
+
133
+ def test_fetcher_app_defaults_to_default_path_builder():
134
+ app = make_app()
135
+ assert app.path_builder is default_path_builder
136
+
137
+
138
+ def test_fetcher_app_accepts_custom_path_builder(tmp_path: Path):
139
+ def custom(output_dir, entry, last_modified):
140
+ return output_dir / "custom.csv"
141
+
142
+ app = make_app(path_builder=custom)
143
+ assert app.path_builder is custom
144
+
145
+
146
+ def test_fetcher_app_default_output():
147
+ app = make_app()
148
+ assert app.default_output == Path("/data/comex")
149
+
150
+
151
+ def test_fetcher_app_default_output_from_name(tmp_path: Path):
152
+ app = make_app(name="foo-fetcher")
153
+ assert app.default_output == Path("/data/foo")
154
+
155
+
156
+ # ---------------------------------------------------------------
157
+ # FetcherApp: attach_command e build_default_commands
158
+ # ---------------------------------------------------------------
159
+
160
+
161
+ def test_attach_command_inherits_default_commands():
162
+ def ping() -> None:
163
+ return None
164
+
165
+ app = make_app()
166
+ app.attach_command(ping)
167
+ result = runner.invoke(app.app, ["ping"])
168
+ assert result.exit_code == 0, result.output
169
+ names = [cmd.name for cmd in app.app.registered_commands]
170
+ assert {"sync", "list"} <= set(names)
171
+
172
+
173
+ def test_attach_command_with_custom_name_and_kwargs(tmp_path: Path):
174
+ def convert(
175
+ input: Annotated[Path, typer.Argument(help="Arquivo de entrada")],
176
+ ) -> None:
177
+ typer.echo(f"converted {input.name}")
178
+
179
+ app = make_app(build_default_commands=False)
180
+ app.attach_command(convert, name="conv", no_args_is_help=True)
181
+ names = [cmd.name for cmd in app.app.registered_commands]
182
+ assert names == ["conv"]
183
+
184
+ # run the attached command through the CliRunner
185
+ dummy = tmp_path / "in.csv"
186
+ dummy.write_text("x,y\n1,2\n")
187
+ assert runner.invoke(_grouped(app.app), ["fd", "conv", str(dummy)]).exit_code == 0
188
+
189
+
190
+ def test_build_default_commands_false_removes_sync_and_list():
191
+ app = make_app(build_default_commands=False)
192
+ names = [cmd.name for cmd in app.app.registered_commands]
193
+ assert "sync" not in names
194
+ assert "list" not in names
195
+
196
+
197
+ def test_attach_command_invocation_output(tmp_path: Path):
198
+ fetcher = make_app(build_default_commands=False)
199
+
200
+ def hello(
201
+ verbose: Annotated[
202
+ bool, typer.Option("--verbose", help="Logs detalhados")
203
+ ] = False,
204
+ ) -> None:
205
+ if verbose:
206
+ typer.echo("verbose")
207
+ typer.echo("hello")
208
+
209
+ fetcher.attach_command(hello)
210
+ result = runner.invoke(_grouped(fetcher.app), ["fd", "hello", "--verbose"])
211
+ assert result.exit_code == 0, result.output
212
+ assert "verbose" in result.output
213
+
214
+
215
+ # ---------------------------------------------------------------
216
+ # make_resolve_groups
217
+ # ---------------------------------------------------------------
218
+
219
+
220
+ def test_make_resolve_groups_expands_aliases():
221
+ resolve = make_resolve_groups(GROUPS, ALIASES)
222
+ assert resolve(["trade"]) == ["exp", "imp"]
223
+
224
+
225
+ def test_make_resolve_groups_dedups_and_preserves_order():
226
+ resolve = make_resolve_groups(GROUPS, ALIASES)
227
+ assert resolve(["imp", "trade", "exp"]) == ["imp", "exp"]
228
+
229
+
230
+ def test_make_resolve_groups_none_returns_all_groups():
231
+ resolve = make_resolve_groups(GROUPS, ALIASES)
232
+ assert resolve(None) == ["exp", "imp"]
233
+
234
+
235
+ def test_make_resolve_groups_unknown_raises():
236
+ resolve = make_resolve_groups(GROUPS, ALIASES)
237
+ with pytest.raises(ValueError, match="Grupo desconhecido: 'zzz'"):
238
+ resolve(["zzz"])
239
+
240
+
241
+ def test_make_resolve_groups_alias_to_empty_raises():
242
+ resolve = make_resolve_groups(GROUPS, ALIASES)
243
+ with pytest.raises(ValueError, match="Grupo desconhecido: 'empty'"):
244
+ resolve(["empty"])
245
+
246
+
247
+ def test_resolver_shared_between_instances():
248
+ app_a = make_app()
249
+ app_b = make_app()
250
+ assert app_a.resolve_groups(["trade"]) == ["exp", "imp"]
251
+ assert app_b.resolve_groups(["exp"]) == ["exp"]
252
+
253
+
254
+ # ---------------------------------------------------------------
255
+ # Integração: sync command com resolver (dry-run)
256
+ # ---------------------------------------------------------------
257
+
258
+
259
+ def test_builtin_sync_dry_run_with_alias():
260
+ app = make_app()
261
+ result = runner.invoke(app.app, ["sync", "trade", "--dry-run"])
262
+ assert result.exit_code == 0, result.output
263
+ assert "2 arquivo(s) listado(s)" in result.output
264
+
265
+
266
+ def test_builtin_sync_unknown_group_exits_1():
267
+ app = make_app()
268
+ result = runner.invoke(app.app, ["sync", "zzz", "--dry-run"])
269
+ assert result.exit_code == 1
270
+ assert "Grupo desconhecido: 'zzz'" in result.output
271
+
272
+
273
+ def test_builtin_list():
274
+ app = make_app()
275
+ result = runner.invoke(app.app, ["list"])
276
+ assert result.exit_code == 0, result.output
277
+ assert "2 dataset(s) no catálogo." in result.output
@@ -1,38 +0,0 @@
1
- name: Test
2
-
3
- on:
4
- push:
5
- branches: [main]
6
- pull_request:
7
- branches: [main]
8
-
9
- jobs:
10
- test:
11
- name: Test (Python ${{ matrix.python-version }})
12
- runs-on: ubuntu-latest
13
- strategy:
14
- fail-fast: false
15
- matrix:
16
- python-version: ["3.12", "3.13"]
17
-
18
- steps:
19
- - uses: actions/checkout@v4
20
-
21
- - name: Install uv
22
- uses: astral-sh/setup-uv@v5
23
- with:
24
- enable-cache: true
25
-
26
- - name: Set up Python ${{ matrix.python-version }}
27
- run: uv python install ${{ matrix.python-version }}
28
-
29
- - name: Install dependencies
30
- run: uv sync --group dev
31
-
32
- - name: Lint with ruff
33
- run: |
34
- uv run ruff check src/ tests/
35
- uv run ruff format --check src/ tests/
36
-
37
- - name: Run tests
38
- run: uv run pytest
File without changes
File without changes