quantilica-cli 0.3.1__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.
- quantilica_cli-0.7.0/.githooks/pre-push +30 -0
- quantilica_cli-0.7.0/.github/workflows/test.yml +55 -0
- quantilica_cli-0.7.0/CHANGELOG.md +74 -0
- {quantilica_cli-0.3.1 → quantilica_cli-0.7.0}/PKG-INFO +2 -2
- {quantilica_cli-0.3.1 → quantilica_cli-0.7.0}/pyproject.toml +4 -6
- {quantilica_cli-0.3.1 → quantilica_cli-0.7.0/src}/quantilica/cli/sdk.py +198 -23
- quantilica_cli-0.7.0/tests/test_sdk.py +277 -0
- quantilica_cli-0.3.1/.github/workflows/test.yml +0 -38
- quantilica_cli-0.3.1/CHANGELOG.md +0 -41
- {quantilica_cli-0.3.1 → quantilica_cli-0.7.0}/.githooks/pre-commit +0 -0
- {quantilica_cli-0.3.1 → quantilica_cli-0.7.0}/.github/workflows/publish.yml +0 -0
- {quantilica_cli-0.3.1 → quantilica_cli-0.7.0}/.gitignore +0 -0
- {quantilica_cli-0.3.1 → quantilica_cli-0.7.0}/LICENSE +0 -0
- {quantilica_cli-0.3.1 → quantilica_cli-0.7.0}/README.md +0 -0
- {quantilica_cli-0.3.1 → quantilica_cli-0.7.0/src}/quantilica/cli/__init__.py +0 -0
- {quantilica_cli-0.3.1 → quantilica_cli-0.7.0/src}/quantilica/cli/cli.py +0 -0
- {quantilica_cli-0.3.1 → quantilica_cli-0.7.0/src}/quantilica/cli/manifests.py +0 -0
- {quantilica_cli-0.3.1 → quantilica_cli-0.7.0/src}/quantilica/cli/progress.py +0 -0
- {quantilica_cli-0.3.1 → quantilica_cli-0.7.0/src}/quantilica/cli/sources.py +0 -0
- {quantilica_cli-0.3.1 → quantilica_cli-0.7.0/src}/quantilica/cli/ui.py +0 -0
- {quantilica_cli-0.3.1 → quantilica_cli-0.7.0/src}/quantilica/py.typed +0 -0
- {quantilica_cli-0.3.1 → quantilica_cli-0.7.0}/tests/__init__.py +0 -0
- {quantilica_cli-0.3.1 → quantilica_cli-0.7.0}/tests/test_manifests.py +0 -0
- {quantilica_cli-0.3.1 → quantilica_cli-0.7.0}/tests/test_sources.py +0 -0
- {quantilica_cli-0.3.1 → 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
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
|
|
4
|
+
|
|
5
|
+
O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
|
|
6
|
+
e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).
|
|
7
|
+
|
|
8
|
+
## [0.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
|
+
|
|
36
|
+
## [0.3.2] - 2026-08-22
|
|
37
|
+
|
|
38
|
+
### Corrigido
|
|
39
|
+
- **Crítico:** wheels publicados desde a 0.3.0 vinham **sem os módulos do pacote** — o diretório `quantilica/cli/` não era incluído no build por configuração incorreta do hatchling (`sources` na seção global e `packages` apontando para o subpacote). Instalações via pip/uv reportavam sucesso, mas `import quantilica.cli` falhava em qualquer ambiente não-editable. Configuração realinhada ao padrão dos pacotes irmãos (`packages = ["src/quantilica"]` dentro de `[tool.hatch.build.targets.wheel]`).
|
|
40
|
+
|
|
41
|
+
## [0.3.1] - 2026-08-22
|
|
42
|
+
|
|
43
|
+
### Corrigido
|
|
44
|
+
- `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).
|
|
45
|
+
- `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`).
|
|
46
|
+
|
|
47
|
+
## [0.3.0] - 2026-08-10
|
|
48
|
+
|
|
49
|
+
### Adicionado
|
|
50
|
+
- Extensão da `FetcherApp` (`sdk.py`) com suporte opcional para `FtpClient`.
|
|
51
|
+
- Exposição do método `download_datasets` na `FetcherApp` para uso por comandos Typer customizados nos plugins.
|
|
52
|
+
- Aceitação e passagem do parâmetro `aliases_dict` para personalização total dos subcomandos por fetcher.
|
|
53
|
+
|
|
54
|
+
### Alterado
|
|
55
|
+
- Padrão de metadados do pacote portado integralmente para a PEP 639 (licença) e PEP 561 (tipagem estática).
|
|
56
|
+
|
|
57
|
+
## [0.2.2] - 2026-07-28
|
|
58
|
+
*(Release retroativo não documentado)*
|
|
59
|
+
|
|
60
|
+
## [0.2.0] - 2026-07-15
|
|
61
|
+
*(Release retroativo não documentado)*
|
|
62
|
+
|
|
63
|
+
## [0.1.0] - 2026-06-04
|
|
64
|
+
|
|
65
|
+
Primeira entrada em formato Keep a Changelog; documenta o estado do pacote nesta
|
|
66
|
+
versão.
|
|
67
|
+
|
|
68
|
+
### Adicionado
|
|
69
|
+
|
|
70
|
+
- CLI unificada `quantilica` que descobre e monta os fetchers instalados via
|
|
71
|
+
entry points `quantilica.fetchers`, sem depender diretamente dos pacotes de
|
|
72
|
+
fetcher.
|
|
73
|
+
- Comando `list-sources` e montagem automática dos sub-apps Typer de cada fetcher
|
|
74
|
+
instalado (`quantilica <fonte> ...`).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: quantilica-cli
|
|
3
|
-
Version: 0.
|
|
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.
|
|
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
|
+
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.
|
|
23
|
+
"quantilica-core>=0.7.0",
|
|
24
24
|
"rich>=13.0.0",
|
|
25
25
|
"typer>=0.15.0",
|
|
26
26
|
]
|
|
@@ -43,11 +43,9 @@ dev = ["ruff>=0.9.0", "pytest>=8.0.0"]
|
|
|
43
43
|
# [tool.hatch.metadata]
|
|
44
44
|
# allow-direct-references = true
|
|
45
45
|
|
|
46
|
-
[tool.hatch.build]
|
|
47
|
-
sources = ["src"]
|
|
48
|
-
|
|
49
46
|
[tool.hatch.build.targets.wheel]
|
|
50
|
-
|
|
47
|
+
sources = ["src"]
|
|
48
|
+
packages = ["src/quantilica"]
|
|
51
49
|
|
|
52
50
|
[build-system]
|
|
53
51
|
requires = ["hatchling>=1.27"]
|
|
@@ -8,7 +8,8 @@ from __future__ import annotations
|
|
|
8
8
|
import concurrent.futures
|
|
9
9
|
import contextlib
|
|
10
10
|
import datetime as dt
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
|
|
4
|
-
|
|
5
|
-
O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
|
|
6
|
-
e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).
|
|
7
|
-
|
|
8
|
-
## [0.3.1] - 2026-08-22
|
|
9
|
-
|
|
10
|
-
### Corrigido
|
|
11
|
-
- `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).
|
|
12
|
-
- `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`).
|
|
13
|
-
|
|
14
|
-
## [0.3.0] - 2026-08-10
|
|
15
|
-
|
|
16
|
-
### Adicionado
|
|
17
|
-
- Extensão da `FetcherApp` (`sdk.py`) com suporte opcional para `FtpClient`.
|
|
18
|
-
- Exposição do método `download_datasets` na `FetcherApp` para uso por comandos Typer customizados nos plugins.
|
|
19
|
-
- Aceitação e passagem do parâmetro `aliases_dict` para personalização total dos subcomandos por fetcher.
|
|
20
|
-
|
|
21
|
-
### Alterado
|
|
22
|
-
- Padrão de metadados do pacote portado integralmente para a PEP 639 (licença) e PEP 561 (tipagem estática).
|
|
23
|
-
|
|
24
|
-
## [0.2.2] - 2026-07-28
|
|
25
|
-
*(Release retroativo não documentado)*
|
|
26
|
-
|
|
27
|
-
## [0.2.0] - 2026-07-15
|
|
28
|
-
*(Release retroativo não documentado)*
|
|
29
|
-
|
|
30
|
-
## [0.1.0] - 2026-06-04
|
|
31
|
-
|
|
32
|
-
Primeira entrada em formato Keep a Changelog; documenta o estado do pacote nesta
|
|
33
|
-
versão.
|
|
34
|
-
|
|
35
|
-
### Adicionado
|
|
36
|
-
|
|
37
|
-
- CLI unificada `quantilica` que descobre e monta os fetchers instalados via
|
|
38
|
-
entry points `quantilica.fetchers`, sem depender diretamente dos pacotes de
|
|
39
|
-
fetcher.
|
|
40
|
-
- Comando `list-sources` e montagem automática dos sub-apps Typer de cada fetcher
|
|
41
|
-
instalado (`quantilica <fonte> ...`).
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|