quantilica-cli 0.3.0__tar.gz → 0.3.2__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.3.0 → quantilica_cli-0.3.2}/CHANGELOG.md +11 -0
- {quantilica_cli-0.3.0 → quantilica_cli-0.3.2}/PKG-INFO +3 -2
- {quantilica_cli-0.3.0 → quantilica_cli-0.3.2}/README.md +1 -0
- {quantilica_cli-0.3.0 → quantilica_cli-0.3.2}/pyproject.toml +4 -6
- {quantilica_cli-0.3.0 → quantilica_cli-0.3.2/src}/quantilica/cli/cli.py +1 -0
- {quantilica_cli-0.3.0 → quantilica_cli-0.3.2/src}/quantilica/cli/manifests.py +17 -1
- {quantilica_cli-0.3.0 → quantilica_cli-0.3.2/src}/quantilica/cli/progress.py +17 -1
- {quantilica_cli-0.3.0 → quantilica_cli-0.3.2/src}/quantilica/cli/sdk.py +44 -5
- {quantilica_cli-0.3.0 → quantilica_cli-0.3.2/src}/quantilica/cli/sources.py +65 -13
- {quantilica_cli-0.3.0 → quantilica_cli-0.3.2/src}/quantilica/cli/ui.py +55 -5
- {quantilica_cli-0.3.0 → quantilica_cli-0.3.2}/.githooks/pre-commit +0 -0
- {quantilica_cli-0.3.0 → quantilica_cli-0.3.2}/.github/workflows/publish.yml +0 -0
- {quantilica_cli-0.3.0 → quantilica_cli-0.3.2}/.github/workflows/test.yml +0 -0
- {quantilica_cli-0.3.0 → quantilica_cli-0.3.2}/.gitignore +0 -0
- {quantilica_cli-0.3.0 → quantilica_cli-0.3.2}/LICENSE +0 -0
- {quantilica_cli-0.3.0 → quantilica_cli-0.3.2/src}/quantilica/cli/__init__.py +0 -0
- {quantilica_cli-0.3.0 → quantilica_cli-0.3.2/src}/quantilica/py.typed +0 -0
- {quantilica_cli-0.3.0 → quantilica_cli-0.3.2}/tests/__init__.py +0 -0
- {quantilica_cli-0.3.0 → quantilica_cli-0.3.2}/tests/test_manifests.py +0 -0
- {quantilica_cli-0.3.0 → quantilica_cli-0.3.2}/tests/test_sources.py +0 -0
- {quantilica_cli-0.3.0 → quantilica_cli-0.3.2}/tests/test_ui.py +0 -0
|
@@ -5,6 +5,17 @@ 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.3.2] - 2026-08-22
|
|
9
|
+
|
|
10
|
+
### Corrigido
|
|
11
|
+
- **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]`).
|
|
12
|
+
|
|
13
|
+
## [0.3.1] - 2026-08-22
|
|
14
|
+
|
|
15
|
+
### Corrigido
|
|
16
|
+
- `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).
|
|
17
|
+
- `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`).
|
|
18
|
+
|
|
8
19
|
## [0.3.0] - 2026-08-10
|
|
9
20
|
|
|
10
21
|
### Adicionado
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: quantilica-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
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
|
|
@@ -24,6 +24,7 @@ Description-Content-Type: text/markdown
|
|
|
24
24
|
# quantilica-cli
|
|
25
25
|
|
|
26
26
|
CLI unificada para o ecossistema Quantilica de dados abertos brasileiros.
|
|
27
|
+
For full documentation, please visit [https://docs.quantilica.com](https://docs.quantilica.com).
|
|
27
28
|
|
|
28
29
|
## Instalação
|
|
29
30
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "quantilica-cli"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.2"
|
|
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" }]
|
|
@@ -38,16 +38,14 @@ dev = ["ruff>=0.9.0", "pytest>=8.0.0"]
|
|
|
38
38
|
|
|
39
39
|
# Para desenvolvimento local do quantilica-core em conjunto com o CLI:
|
|
40
40
|
# cd quantilica-cli && uv pip install -e ../quantilica-core (sem [tool.uv.sources])
|
|
41
|
-
# Em produção, quantilica-core
|
|
41
|
+
# Em produção, quantilica-core resolve pelo PyPI (pin de registro em dependencies).
|
|
42
42
|
|
|
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"]
|
|
@@ -47,7 +47,17 @@ _STALE_GRACE = 1.5
|
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
def parse_since(value: str) -> datetime:
|
|
50
|
-
"""Parse a relative duration ('7d', '24h') or ISO date into a UTC cutoff.
|
|
50
|
+
"""Parse a relative duration ('7d', '24h') or ISO date into a UTC cutoff.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
value: The string duration or ISO date to parse.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
The computed UTC cutoff datetime.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
typer.BadParameter: If the value is neither a valid duration nor an ISO date.
|
|
60
|
+
"""
|
|
51
61
|
match = _DURATION_RE.match(value.strip().lower())
|
|
52
62
|
if match:
|
|
53
63
|
amount, unit = int(match.group(1)), match.group(2)
|
|
@@ -101,6 +111,12 @@ def iter_manifests(root: Path) -> Iterator[tuple[Path, dict[str, Any]]]:
|
|
|
101
111
|
"""Yield ``(manifest_path, data)`` for every manifest found under ``root``.
|
|
102
112
|
|
|
103
113
|
Malformed JSON files are skipped with a warning rather than aborting.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
root: The root directory to search for manifests.
|
|
117
|
+
|
|
118
|
+
Yields:
|
|
119
|
+
A tuple containing the path to the manifest and its parsed JSON data.
|
|
104
120
|
"""
|
|
105
121
|
for path in sorted(root.glob(MANIFEST_GLOB)):
|
|
106
122
|
if not path.is_file():
|
|
@@ -21,6 +21,14 @@ def file_progress(
|
|
|
21
21
|
|
|
22
22
|
Designed for use with HttpClient.download_with_manifest(progress=...).
|
|
23
23
|
``total`` is the expected file size in bytes; pass 0 when unknown.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
description: The text description to show on the progress bar.
|
|
27
|
+
total: Expected file size in bytes. Defaults to 0.
|
|
28
|
+
leave: Whether to leave the progress bar upon completion. Defaults to False.
|
|
29
|
+
|
|
30
|
+
Yields:
|
|
31
|
+
A callback function to update progress.
|
|
24
32
|
"""
|
|
25
33
|
pbar: tqdm[int] = tqdm(
|
|
26
34
|
total=total or None,
|
|
@@ -56,6 +64,14 @@ def batch_progress(
|
|
|
56
64
|
*,
|
|
57
65
|
total: int,
|
|
58
66
|
) -> Generator[tqdm[int], None, None]:
|
|
59
|
-
"""Context manager for tracking overall batch progress (file count).
|
|
67
|
+
"""Context manager for tracking overall batch progress (file count).
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
description: The text description for the batch progress.
|
|
71
|
+
total: Total number of files/items to track.
|
|
72
|
+
|
|
73
|
+
Yields:
|
|
74
|
+
A tqdm progress bar instance.
|
|
75
|
+
"""
|
|
60
76
|
with tqdm(total=total, desc=description, unit="arquivo", leave=True) as pbar:
|
|
61
77
|
yield pbar
|
|
@@ -34,6 +34,11 @@ logger = get_logger(__name__)
|
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
def default_client() -> HttpClient:
|
|
37
|
+
"""Create a default HttpClient with standard configuration.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
A pre-configured HttpClient instance.
|
|
41
|
+
"""
|
|
37
42
|
return HttpClient(
|
|
38
43
|
timeout=180.0,
|
|
39
44
|
verify=True,
|
|
@@ -50,7 +55,18 @@ def default_client() -> HttpClient:
|
|
|
50
55
|
|
|
51
56
|
|
|
52
57
|
class FetcherApp:
|
|
53
|
-
"""Standard orchestrator for Quantilica fetchers.
|
|
58
|
+
"""Standard orchestrator for Quantilica fetchers.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
name: Name of the fetcher (e.g., 'comex-fetcher').
|
|
62
|
+
help: Help text for the CLI.
|
|
63
|
+
groups_dict: Dictionary of dataset groups and their metadata.
|
|
64
|
+
aliases_dict: Dictionary of alias mappings to dataset groups.
|
|
65
|
+
list_datasets: Callback to list datasets given a group ID.
|
|
66
|
+
path_builder: Callback to build the destination path.
|
|
67
|
+
default_output: Default output directory path.
|
|
68
|
+
client: HTTP or FTP client instance. Defaults to default_client().
|
|
69
|
+
"""
|
|
54
70
|
|
|
55
71
|
def __init__(
|
|
56
72
|
self,
|
|
@@ -108,7 +124,21 @@ class FetcherApp:
|
|
|
108
124
|
dry_run: bool = False,
|
|
109
125
|
progress: ProgressCallback | None = None,
|
|
110
126
|
) -> Path:
|
|
111
|
-
"""Download one dataset entry and return the destination path.
|
|
127
|
+
"""Download one dataset entry and return the destination path.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
entry: Dictionary containing dataset metadata (url, id, etc).
|
|
131
|
+
output_dir: Destination directory.
|
|
132
|
+
dry_run: If True, computes the destination path without downloading.
|
|
133
|
+
progress: Optional callback to track download progress.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
The local path where the file was (or would be) saved.
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
FetchError: If no valid URLs could be downloaded.
|
|
140
|
+
HttpStatusError: On non-404 HTTP errors.
|
|
141
|
+
"""
|
|
112
142
|
urls_to_try = [entry["url"]]
|
|
113
143
|
if "fallback_urls" in entry and entry["fallback_urls"]:
|
|
114
144
|
urls_to_try.extend(entry["fallback_urls"])
|
|
@@ -155,9 +185,18 @@ class FetcherApp:
|
|
|
155
185
|
output_dir: Path,
|
|
156
186
|
workers: int = 4,
|
|
157
187
|
) -> tuple[int, int, list[tuple[str, str]]]:
|
|
158
|
-
"""
|
|
159
|
-
|
|
160
|
-
|
|
188
|
+
"""Executes parallel download for a list of dataset entries.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
entries: List of dataset entries to download.
|
|
192
|
+
output_dir: The target base directory for downloads.
|
|
193
|
+
workers: Maximum number of parallel downloads.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
A tuple containing:
|
|
197
|
+
- downloaded_count: Number of successfully downloaded files.
|
|
198
|
+
- total_count: Total number of files attempted.
|
|
199
|
+
- errors_list: A list of tuples containing the dataset ID and the error message.
|
|
161
200
|
"""
|
|
162
201
|
console = get_console()
|
|
163
202
|
total = len(entries)
|
|
@@ -22,7 +22,7 @@ app = typer.Typer(
|
|
|
22
22
|
console = Console()
|
|
23
23
|
|
|
24
24
|
FETCHER_GROUP = "quantilica.fetchers"
|
|
25
|
-
DEFAULT_INDEX_URL = "https://quantilica.com/
|
|
25
|
+
DEFAULT_INDEX_URL = "https://index.quantilica.com/simple/"
|
|
26
26
|
|
|
27
27
|
# Mapeamento estático padrão de comandos CLI para nome das distribuições
|
|
28
28
|
SOURCES_REGISTRY: dict[str, str] = {
|
|
@@ -40,7 +40,11 @@ SOURCES_REGISTRY: dict[str, str] = {
|
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
def get_config_dir() -> Path:
|
|
43
|
-
"""Retorna o diretório de configuração do quantilica-cli.
|
|
43
|
+
"""Retorna o diretório de configuração do quantilica-cli.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
O caminho do diretório de configuração.
|
|
47
|
+
"""
|
|
44
48
|
custom = os.environ.get("QUANTILICA_CONFIG_DIR")
|
|
45
49
|
if custom:
|
|
46
50
|
return Path(custom)
|
|
@@ -48,12 +52,20 @@ def get_config_dir() -> Path:
|
|
|
48
52
|
|
|
49
53
|
|
|
50
54
|
def get_state_file() -> Path:
|
|
51
|
-
"""Retorna o caminho para o arquivo de estado local.
|
|
55
|
+
"""Retorna o caminho para o arquivo de estado local.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
O caminho para o arquivo de estado `state.toml`.
|
|
59
|
+
"""
|
|
52
60
|
return get_config_dir() / "state.toml"
|
|
53
61
|
|
|
54
62
|
|
|
55
63
|
def load_state() -> dict[str, Any]:
|
|
56
|
-
"""Lê o arquivo de estado em ~/.config/quantilica/state.toml.
|
|
64
|
+
"""Lê o arquivo de estado em ~/.config/quantilica/state.toml.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Um dicionário contendo o estado carregado.
|
|
68
|
+
"""
|
|
57
69
|
state_file = get_state_file()
|
|
58
70
|
if not state_file.exists():
|
|
59
71
|
return {"installed": {}}
|
|
@@ -71,7 +83,11 @@ def load_state() -> dict[str, Any]:
|
|
|
71
83
|
|
|
72
84
|
|
|
73
85
|
def save_state(state: dict[str, Any]) -> None:
|
|
74
|
-
"""Salva o arquivo de estado em ~/.config/quantilica/state.toml.
|
|
86
|
+
"""Salva o arquivo de estado em ~/.config/quantilica/state.toml.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
state: O dicionário de estado a ser salvo.
|
|
90
|
+
"""
|
|
75
91
|
config_dir = get_config_dir()
|
|
76
92
|
config_dir.mkdir(parents=True, exist_ok=True)
|
|
77
93
|
state_file = get_state_file()
|
|
@@ -85,18 +101,34 @@ def save_state(state: dict[str, Any]) -> None:
|
|
|
85
101
|
|
|
86
102
|
|
|
87
103
|
def get_installed_entry_points() -> set[str]:
|
|
88
|
-
"""Retorna o conjunto de nomes de entry points de fetchers atualmente instalados.
|
|
104
|
+
"""Retorna o conjunto de nomes de entry points de fetchers atualmente instalados.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Um conjunto de nomes de entry points.
|
|
108
|
+
"""
|
|
89
109
|
eps = entry_points(group=FETCHER_GROUP)
|
|
90
110
|
return {ep.name for ep in eps}
|
|
91
111
|
|
|
92
112
|
|
|
93
113
|
def get_index_url() -> str:
|
|
94
|
-
"""Retorna a URL do índice pip customizado ou default.
|
|
114
|
+
"""Retorna a URL do índice pip customizado ou default.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
A URL do índice pip.
|
|
118
|
+
"""
|
|
95
119
|
return os.environ.get("QUANTILICA_INDEX_URL", DEFAULT_INDEX_URL)
|
|
96
120
|
|
|
97
121
|
|
|
98
122
|
def install_package(dist_name: str, index_url: str | None = None) -> None:
|
|
99
|
-
"""Instala um pacote Python usando uv pip install se disponível, ou pip.
|
|
123
|
+
"""Instala um pacote Python usando uv pip install se disponível, ou pip.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
dist_name: Nome do pacote a instalar.
|
|
127
|
+
index_url: URL do índice pip. Padrão para a URL do índice global se não especificada.
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
RuntimeError: Se a instalação falhar.
|
|
131
|
+
"""
|
|
100
132
|
idx = index_url or get_index_url()
|
|
101
133
|
|
|
102
134
|
# Prepara o ambiente garantindo que VIRTUAL_ENV aponte para o ambiente atual
|
|
@@ -135,7 +167,14 @@ def install_package(dist_name: str, index_url: str | None = None) -> None:
|
|
|
135
167
|
|
|
136
168
|
|
|
137
169
|
def uninstall_package(dist_name: str) -> None:
|
|
138
|
-
"""Desinstala um pacote Python usando uv pip uninstall se disponível, ou pip.
|
|
170
|
+
"""Desinstala um pacote Python usando uv pip uninstall se disponível, ou pip.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
dist_name: Nome do pacote a desinstalar.
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
RuntimeError: Se a desinstalação falhar.
|
|
177
|
+
"""
|
|
139
178
|
env = os.environ.copy()
|
|
140
179
|
venv_dir = str(Path(sys.executable).parent.parent)
|
|
141
180
|
env["VIRTUAL_ENV"] = venv_dir
|
|
@@ -154,7 +193,11 @@ def uninstall_package(dist_name: str) -> None:
|
|
|
154
193
|
|
|
155
194
|
|
|
156
195
|
def fetch_remote_sources() -> dict[str, str]:
|
|
157
|
-
"""Obtém a lista remota de fontes disponíveis do arquivo sources.json do índice.
|
|
196
|
+
"""Obtém a lista remota de fontes disponíveis do arquivo sources.json do índice.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Um dicionário mapeando os nomes das fontes para os nomes das distribuições.
|
|
200
|
+
"""
|
|
158
201
|
index_url = get_index_url()
|
|
159
202
|
# Converte simple/ index url para URL base de sources.json
|
|
160
203
|
base_url = index_url.rstrip("/")
|
|
@@ -178,7 +221,11 @@ def fetch_remote_sources() -> dict[str, str]:
|
|
|
178
221
|
|
|
179
222
|
|
|
180
223
|
def reexec_cli(args: list[str]) -> None:
|
|
181
|
-
"""Recarrega o processo CLI com os novos entry points instalados.
|
|
224
|
+
"""Recarrega o processo CLI com os novos entry points instalados.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
args: Argumentos a serem passados para a nova execução da CLI.
|
|
228
|
+
"""
|
|
182
229
|
if sys.platform == "win32":
|
|
183
230
|
code = subprocess.call([sys.executable, "-m", "quantilica.cli.cli", *args])
|
|
184
231
|
sys.exit(code)
|
|
@@ -186,6 +233,11 @@ def reexec_cli(args: list[str]) -> None:
|
|
|
186
233
|
os.execv(sys.executable, [sys.executable, "-m", "quantilica.cli.cli", *args])
|
|
187
234
|
|
|
188
235
|
|
|
236
|
+
def _merged_registry() -> dict[str, str]:
|
|
237
|
+
"""Combina o registro remoto (sources.json) com o local; o local tem precedência."""
|
|
238
|
+
return {**fetch_remote_sources(), **SOURCES_REGISTRY}
|
|
239
|
+
|
|
240
|
+
|
|
189
241
|
@app.command("install")
|
|
190
242
|
def cmd_install(
|
|
191
243
|
source: str = typer.Argument(
|
|
@@ -196,7 +248,7 @@ def cmd_install(
|
|
|
196
248
|
),
|
|
197
249
|
) -> None:
|
|
198
250
|
"""Instala uma fonte de dados (fetcher) sob demanda."""
|
|
199
|
-
registry =
|
|
251
|
+
registry = _merged_registry()
|
|
200
252
|
dist_name = registry.get(source, source)
|
|
201
253
|
|
|
202
254
|
installed_eps = get_installed_entry_points()
|
|
@@ -242,7 +294,7 @@ def cmd_uninstall(
|
|
|
242
294
|
),
|
|
243
295
|
) -> None:
|
|
244
296
|
"""Desinstala uma fonte de dados (fetcher)."""
|
|
245
|
-
registry =
|
|
297
|
+
registry = _merged_registry()
|
|
246
298
|
dist_name = registry.get(source, source)
|
|
247
299
|
|
|
248
300
|
try:
|
|
@@ -38,7 +38,11 @@ _console: Console | None = None
|
|
|
38
38
|
|
|
39
39
|
|
|
40
40
|
def get_console() -> Console:
|
|
41
|
-
"""Return a process-wide shared Rich console.
|
|
41
|
+
"""Return a process-wide shared Rich console.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
The shared Rich Console instance.
|
|
45
|
+
"""
|
|
42
46
|
global _console
|
|
43
47
|
if _console is None:
|
|
44
48
|
_console = Console()
|
|
@@ -53,6 +57,10 @@ def setup_rich_logging(
|
|
|
53
57
|
"""Configure logging via ``RichHandler`` without breaking progress bars.
|
|
54
58
|
|
|
55
59
|
``verbose=False`` → WARNING only; ``verbose=True`` → DEBUG.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
verbose: Whether to enable verbose logging.
|
|
63
|
+
console: Optional Rich Console to use for logging.
|
|
56
64
|
"""
|
|
57
65
|
level = logging.DEBUG if verbose else logging.WARNING
|
|
58
66
|
logging.basicConfig(
|
|
@@ -65,7 +73,14 @@ def setup_rich_logging(
|
|
|
65
73
|
|
|
66
74
|
|
|
67
75
|
def make_batch_progress(console: Console | None = None) -> Progress:
|
|
68
|
-
"""Build a Progress for overall/batch tracking (file counts).
|
|
76
|
+
"""Build a Progress for overall/batch tracking (file counts).
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
console: Optional Rich Console to use.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
A Rich Progress instance configured for batch tracking.
|
|
83
|
+
"""
|
|
69
84
|
return Progress(
|
|
70
85
|
SpinnerColumn(),
|
|
71
86
|
TextColumn("[progress.description]{task.description}"),
|
|
@@ -78,7 +93,14 @@ def make_batch_progress(console: Console | None = None) -> Progress:
|
|
|
78
93
|
|
|
79
94
|
|
|
80
95
|
def make_download_progress(console: Console | None = None) -> Progress:
|
|
81
|
-
"""Build a Progress for individual file downloads (bytes/speed).
|
|
96
|
+
"""Build a Progress for individual file downloads (bytes/speed).
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
console: Optional Rich Console to use.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
A Rich Progress instance configured for individual file downloads.
|
|
103
|
+
"""
|
|
82
104
|
return Progress(
|
|
83
105
|
SpinnerColumn(),
|
|
84
106
|
TextColumn("[dim]{task.description}[/dim]"),
|
|
@@ -99,6 +121,14 @@ def expand_years_cli(
|
|
|
99
121
|
|
|
100
122
|
If ``years`` is empty and ``default_range`` is provided, it expands the
|
|
101
123
|
default range. Prints a warning to the console/stderr for any invalid specs.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
years: List of year strings or ranges to expand.
|
|
127
|
+
default_range: A default range to use if years is empty.
|
|
128
|
+
console: Optional Rich Console for printing warnings.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
A list of expanded year integers.
|
|
102
132
|
"""
|
|
103
133
|
from quantilica.core.dates import expand_year_range
|
|
104
134
|
|
|
@@ -114,7 +144,12 @@ def expand_years_cli(
|
|
|
114
144
|
|
|
115
145
|
|
|
116
146
|
class ProgressPool:
|
|
117
|
-
"""Manages a fixed pool of rich progress bars for concurrent workers.
|
|
147
|
+
"""Manages a fixed pool of rich progress bars for concurrent workers.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
workers: The number of progress bar tasks to initialize.
|
|
151
|
+
file_prog: The Rich Progress instance to add tasks to.
|
|
152
|
+
"""
|
|
118
153
|
|
|
119
154
|
def __init__(self, workers: int, file_prog: Progress):
|
|
120
155
|
self.lock = threading.Lock()
|
|
@@ -127,6 +162,14 @@ class ProgressPool:
|
|
|
127
162
|
def acquire(
|
|
128
163
|
self, description: str
|
|
129
164
|
) -> Generator[Callable[[int, int], None], None, None]:
|
|
165
|
+
"""Acquires a progress bar task and returns an update callback.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
description: The description to show for this task.
|
|
169
|
+
|
|
170
|
+
Yields:
|
|
171
|
+
A callback function to update progress with downloaded and total bytes.
|
|
172
|
+
"""
|
|
130
173
|
with self.lock:
|
|
131
174
|
task_id = self.available.pop(0)
|
|
132
175
|
self.file_prog.update(task_id, description=description, completed=0, total=None)
|
|
@@ -154,7 +197,14 @@ class ProgressPool:
|
|
|
154
197
|
def graceful_executor(
|
|
155
198
|
max_workers: int,
|
|
156
199
|
) -> Generator[concurrent.futures.ThreadPoolExecutor, None, None]:
|
|
157
|
-
"""ThreadPoolExecutor that cancels futures and shuts down on KeyboardInterrupt.
|
|
200
|
+
"""ThreadPoolExecutor that cancels futures and shuts down on KeyboardInterrupt.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
max_workers: Maximum number of threads in the pool.
|
|
204
|
+
|
|
205
|
+
Yields:
|
|
206
|
+
A thread pool executor instance.
|
|
207
|
+
"""
|
|
158
208
|
executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers)
|
|
159
209
|
# Python 3.9+ supports cancel_futures=True, which automatically
|
|
160
210
|
# cancels pending futures during shutdown.
|
|
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
|