quantilica-cli 0.10.1__tar.gz → 0.11.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.10.1 → quantilica_cli-0.11.0}/CHANGELOG.md +25 -0
- {quantilica_cli-0.10.1 → quantilica_cli-0.11.0}/PKG-INFO +2 -2
- {quantilica_cli-0.10.1 → quantilica_cli-0.11.0}/pyproject.toml +2 -2
- {quantilica_cli-0.10.1 → quantilica_cli-0.11.0}/src/quantilica/cli/sdk.py +255 -30
- {quantilica_cli-0.10.1 → quantilica_cli-0.11.0}/src/quantilica/cli/sources.py +54 -9
- {quantilica_cli-0.10.1 → quantilica_cli-0.11.0}/tests/test_package_hygiene.py +21 -0
- quantilica_cli-0.11.0/tests/test_sdk_force_probe.py +487 -0
- quantilica_cli-0.11.0/tests/test_sdk_mes_string.py +51 -0
- {quantilica_cli-0.10.1 → quantilica_cli-0.11.0}/tests/test_sdk_onda_a2.py +12 -4
- {quantilica_cli-0.10.1 → quantilica_cli-0.11.0}/tests/test_sources.py +91 -0
- quantilica_cli-0.10.1/src/quantilica/cli/progress.py +0 -77
- {quantilica_cli-0.10.1 → quantilica_cli-0.11.0}/.githooks/pre-commit +0 -0
- {quantilica_cli-0.10.1 → quantilica_cli-0.11.0}/.githooks/pre-push +0 -0
- {quantilica_cli-0.10.1 → quantilica_cli-0.11.0}/.github/workflows/publish.yml +0 -0
- {quantilica_cli-0.10.1 → quantilica_cli-0.11.0}/.github/workflows/test.yml +0 -0
- {quantilica_cli-0.10.1 → quantilica_cli-0.11.0}/.gitignore +0 -0
- {quantilica_cli-0.10.1 → quantilica_cli-0.11.0}/LICENSE +0 -0
- {quantilica_cli-0.10.1 → quantilica_cli-0.11.0}/README.md +0 -0
- {quantilica_cli-0.10.1 → quantilica_cli-0.11.0}/src/quantilica/cli/__init__.py +0 -0
- {quantilica_cli-0.10.1 → quantilica_cli-0.11.0}/src/quantilica/cli/cli.py +0 -0
- {quantilica_cli-0.10.1 → quantilica_cli-0.11.0}/src/quantilica/cli/health.py +0 -0
- {quantilica_cli-0.10.1 → quantilica_cli-0.11.0}/src/quantilica/cli/manifests.py +0 -0
- {quantilica_cli-0.10.1 → quantilica_cli-0.11.0}/src/quantilica/cli/ui.py +0 -0
- {quantilica_cli-0.10.1 → quantilica_cli-0.11.0}/src/quantilica/py.typed +0 -0
- {quantilica_cli-0.10.1 → quantilica_cli-0.11.0}/tests/__init__.py +0 -0
- {quantilica_cli-0.10.1 → quantilica_cli-0.11.0}/tests/test_health.py +0 -0
- {quantilica_cli-0.10.1 → quantilica_cli-0.11.0}/tests/test_manifests.py +0 -0
- {quantilica_cli-0.10.1 → quantilica_cli-0.11.0}/tests/test_sdk.py +0 -0
- {quantilica_cli-0.10.1 → quantilica_cli-0.11.0}/tests/test_ui.py +0 -0
|
@@ -5,6 +5,31 @@ 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.11.0] - 2026-10-14
|
|
9
|
+
|
|
10
|
+
### Adicionado
|
|
11
|
+
|
|
12
|
+
- `sync --force` (e `force` keyword-only em `download_datasets`, `download_entry` e `_download_file`, repassado ao cliente só quando `True`): baixa mesmo com o local atualizado.
|
|
13
|
+
- `check --out PATH`: grava o plano JSON de forma atômica e imprime só um resumo; `OSError` sai com código 1.
|
|
14
|
+
- `check`: `HEAD` com 403 ou 405 passa a um probe `GET` com `Range: bytes=0-0` e `Accept-Encoding: identity` (tamanho via `Content-Range`, corpo nunca lido); URL que dá 404 no `HEAD` e no probe vira a ação `skip-unavailable`, aceita por `CheckPlan.from_dict`.
|
|
15
|
+
- `FetcherApp.entry_from_plan`: gancho para fetchers que serializam objetos ricos na entrada do plano.
|
|
16
|
+
- `default_client` lê `QUANTILICA_HTTP_MAX_RESUMES` (opcional), repassado como `max_resumes` ao `HttpClient`.
|
|
17
|
+
- `quantilica install <fonte> --extra <nome>` (`-e`, repetível, aceita lista separada por vírgula): instala `<dist>[<extra>]` no ambiente da CLI, por exemplo `quantilica install pdet --extra analysis` para habilitar `convert` e `pipeline`. Funciona também em fonte já instalada. O estado continua registrando só a distribuição, e o re-exec pós-instalação não repete `--extra`, para não reinstalar em laço. Nomes de extra inválidos saem com código 2.
|
|
18
|
+
|
|
19
|
+
### Alterado
|
|
20
|
+
|
|
21
|
+
- Piso de `quantilica-core` elevado para `>=0.11.0` (símbolo usado: `max_resumes` em `HttpClient`).
|
|
22
|
+
|
|
23
|
+
### Removido
|
|
24
|
+
|
|
25
|
+
- `quantilica.cli.progress` (`file_progress`, `batch_progress`): módulo morto que nunca importou. Fazia `from .http import ProgressCallback` (não existe em `quantilica.cli`; o tipo vive em `quantilica.core.http`) e usava `tqdm`, que não é dependência do pacote. Nenhum fetcher o importava; o progresso real é Rich (`quantilica.cli.ui.make_download_progress`, `make_batch_progress`, `ProgressPool`).
|
|
26
|
+
|
|
27
|
+
### Corrigido
|
|
28
|
+
|
|
29
|
+
- `install_package` imprimia `Instalando pdet-fetcher...` sem o `[analysis]`: o Rich tratava o colchete como marcação. Agora o nome é escapado.
|
|
30
|
+
- `sdk`: a dica de extra ausente em `convert`, `archive` e `pipeline` mandava `pip install <fetcher>[analysis]`, que não funciona para fetchers do Fluxo B (fora do PyPI). Agora imprime `quantilica install <chave> --extra analysis`, com a chave de registro resolvida pelo entry point (por exemplo `td` para `tesouro-direto-fetcher`).
|
|
31
|
+
- `sdk`: `quantilica <fonte> list` (e o rótulo de partição do `check`/`sync`) terminava com `ValueError: Unknown format code 'd' for object of type 'str'` quando o catálogo traz `month` como string, caso do PDET (grupo capturado de regex). O mês e o semestre agora passam por `int()` antes do `:02d`, em `_partition_label`, `default_path_builder` e `list`; o `list` reutiliza `_partition_label` em vez de duplicar a lógica.
|
|
32
|
+
|
|
8
33
|
## [0.10.1] - 2026-10-08
|
|
9
34
|
|
|
10
35
|
### Corrigido
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: quantilica-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.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.11.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.11.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.11.0",
|
|
24
24
|
"rich>=13.0.0",
|
|
25
25
|
"typer>=0.15.0",
|
|
26
26
|
]
|
|
@@ -14,6 +14,7 @@ import os
|
|
|
14
14
|
import threading
|
|
15
15
|
from collections.abc import Callable, Iterable
|
|
16
16
|
from dataclasses import dataclass, field
|
|
17
|
+
from importlib.metadata import entry_points
|
|
17
18
|
from pathlib import Path
|
|
18
19
|
from typing import Annotated, Any
|
|
19
20
|
|
|
@@ -125,12 +126,22 @@ class SyncPlan:
|
|
|
125
126
|
)
|
|
126
127
|
|
|
127
128
|
|
|
129
|
+
def _two_digits(value: Any) -> str:
|
|
130
|
+
"""Format a month/semester as two digits, accepting ``int`` or numeric ``str``.
|
|
131
|
+
|
|
132
|
+
Catalogs built from filename regexes (e.g. PDET) carry the month as the
|
|
133
|
+
captured string (``"03"``), while others use ``int``; ``f"{x:02d}"`` only
|
|
134
|
+
works for the latter.
|
|
135
|
+
"""
|
|
136
|
+
return f"{int(value):02d}"
|
|
137
|
+
|
|
138
|
+
|
|
128
139
|
def _partition_label(entry: dict[str, Any]) -> str | None:
|
|
129
140
|
"""Human-readable partition label for a dataset entry."""
|
|
130
141
|
if entry.get("semester") is not None:
|
|
131
142
|
return f"{entry['year']}-S{entry['semester']}"
|
|
132
143
|
if entry.get("month") is not None:
|
|
133
|
-
return f"{entry['year']}-{entry['month']
|
|
144
|
+
return f"{entry['year']}-{_two_digits(entry['month'])}"
|
|
134
145
|
if entry.get("year") is not None:
|
|
135
146
|
return str(entry["year"])
|
|
136
147
|
return None
|
|
@@ -154,6 +165,10 @@ def _short_remote(item: CheckPlanItem) -> str:
|
|
|
154
165
|
return remote
|
|
155
166
|
|
|
156
167
|
|
|
168
|
+
class _ProbeNotFoundError(Exception):
|
|
169
|
+
"""O GET de probe Range respondeu 404 (URL removida no servidor)."""
|
|
170
|
+
|
|
171
|
+
|
|
157
172
|
@dataclass(frozen=True)
|
|
158
173
|
class CheckPlanItem:
|
|
159
174
|
"""Freshness verdict for a single dataset entry (no download performed).
|
|
@@ -170,7 +185,8 @@ class CheckPlanItem:
|
|
|
170
185
|
local_exists: Whether the artifact already exists locally — this
|
|
171
186
|
covers either the raw file itself or its ephemeral ``.manifest.json``
|
|
172
187
|
sidecar marker (file deleted for retention, sidecar still fresh).
|
|
173
|
-
action: ``"download"
|
|
188
|
+
action: ``"download"``, ``"skip-up-to-date"`` or ``"skip-unavailable"``
|
|
189
|
+
(todas as URLs da entrada responderam 404 no HEAD).
|
|
174
190
|
reason: Machine-readable reason for the action.
|
|
175
191
|
entry: Original dataset entry (allows ``sync --from-plan`` round-trip).
|
|
176
192
|
"""
|
|
@@ -225,11 +241,7 @@ class CheckPlan:
|
|
|
225
241
|
_short_remote(item),
|
|
226
242
|
)
|
|
227
243
|
con.print(table)
|
|
228
|
-
|
|
229
|
-
for item in self.items:
|
|
230
|
-
counts[item.action] = counts.get(item.action, 0) + 1
|
|
231
|
-
summary = ", ".join(f"{v} {k}" for k, v in sorted(counts.items()))
|
|
232
|
-
con.print(f"[bold]{len(self.items)}[/bold] verificado(s): {summary}.")
|
|
244
|
+
con.print(self.summary_line())
|
|
233
245
|
|
|
234
246
|
def to_dict(self) -> dict[str, Any]:
|
|
235
247
|
"""Return the JSON-serializable representation of the plan."""
|
|
@@ -256,6 +268,14 @@ class CheckPlan:
|
|
|
256
268
|
],
|
|
257
269
|
}
|
|
258
270
|
|
|
271
|
+
def summary_line(self) -> str:
|
|
272
|
+
"""Return a one-line summary with the total and per-action counts."""
|
|
273
|
+
counts: dict[str, int] = {}
|
|
274
|
+
for item in self.items:
|
|
275
|
+
counts[item.action] = counts.get(item.action, 0) + 1
|
|
276
|
+
summary = ", ".join(f"{v} {k}" for k, v in sorted(counts.items()))
|
|
277
|
+
return f"[bold]{len(self.items)}[/bold] verificado(s): {summary}."
|
|
278
|
+
|
|
259
279
|
def to_json(self) -> str:
|
|
260
280
|
"""Serialize the plan to a JSON string (for ``sync --from-plan``)."""
|
|
261
281
|
return json.dumps(self.to_dict(), ensure_ascii=False, indent=2)
|
|
@@ -282,6 +302,7 @@ class CheckPlan:
|
|
|
282
302
|
if not isinstance(entry, dict) or action not in (
|
|
283
303
|
"download",
|
|
284
304
|
"skip-up-to-date",
|
|
305
|
+
"skip-unavailable",
|
|
285
306
|
):
|
|
286
307
|
raise ValueError("not a check plan: invalid item")
|
|
287
308
|
items.append(
|
|
@@ -332,6 +353,8 @@ def default_client() -> HttpClient:
|
|
|
332
353
|
a path uses it as CA bundle.
|
|
333
354
|
- ``QUANTILICA_HTTP_ATTEMPTS`` / ``QUANTILICA_HTTP_TIMEOUT`` /
|
|
334
355
|
``QUANTILICA_HTTP_RETRY_DELAY``: retry budget overrides.
|
|
356
|
+
- ``QUANTILICA_HTTP_MAX_RESUMES``: teto de retomadas com progresso de
|
|
357
|
+
downloads interrompidos (default do ``HttpClient`` quando ausente).
|
|
335
358
|
|
|
336
359
|
Returns:
|
|
337
360
|
A pre-configured HttpClient instance (browser-like WAF headers + pooling).
|
|
@@ -345,12 +368,18 @@ def default_client() -> HttpClient:
|
|
|
345
368
|
attempts = int(os.environ.get("QUANTILICA_HTTP_ATTEMPTS", "5"))
|
|
346
369
|
timeout = float(os.environ.get("QUANTILICA_HTTP_TIMEOUT", "180.0"))
|
|
347
370
|
retry_base_delay = float(os.environ.get("QUANTILICA_HTTP_RETRY_DELAY", "2.0"))
|
|
371
|
+
# Só repassado quando definido: preserva o default do core.
|
|
372
|
+
extra: dict[str, Any] = {}
|
|
373
|
+
max_resumes = os.environ.get("QUANTILICA_HTTP_MAX_RESUMES", "").strip()
|
|
374
|
+
if max_resumes:
|
|
375
|
+
extra["max_resumes"] = int(max_resumes)
|
|
348
376
|
return HttpClient(
|
|
349
377
|
timeout=timeout,
|
|
350
378
|
verify=verify,
|
|
351
379
|
attempts=attempts,
|
|
352
380
|
retry_base_delay=retry_base_delay,
|
|
353
381
|
emulate_browser=True,
|
|
382
|
+
**extra,
|
|
354
383
|
)
|
|
355
384
|
|
|
356
385
|
|
|
@@ -396,14 +425,14 @@ class DataRepository(StampedDataRepository):
|
|
|
396
425
|
if year is not None and semester is not None:
|
|
397
426
|
filename = build_stamped_filename(
|
|
398
427
|
slug,
|
|
399
|
-
f"{year}-{semester
|
|
428
|
+
f"{year}-{_two_digits(semester)}",
|
|
400
429
|
ext=ext,
|
|
401
430
|
timestamp=last_modified,
|
|
402
431
|
)
|
|
403
432
|
elif year is not None and month is not None:
|
|
404
433
|
filename = build_stamped_filename(
|
|
405
434
|
slug,
|
|
406
|
-
f"{year}-{month
|
|
435
|
+
f"{year}-{_two_digits(month)}",
|
|
407
436
|
ext=ext,
|
|
408
437
|
timestamp=last_modified,
|
|
409
438
|
)
|
|
@@ -543,6 +572,28 @@ class FetcherApp:
|
|
|
543
572
|
"""
|
|
544
573
|
self.app.command(name=name, **kwargs)(cmd_func)
|
|
545
574
|
|
|
575
|
+
def _analysis_hint(self) -> str:
|
|
576
|
+
"""Comando que instala o extra ``analysis`` no ambiente desta CLI.
|
|
577
|
+
|
|
578
|
+
Usa a chave de registro do fetcher (a que ``quantilica install`` e o
|
|
579
|
+
entry point conhecem, ex.: ``td``) e, se não a encontrar, o nome da
|
|
580
|
+
distribuição, que ``quantilica install`` também aceita.
|
|
581
|
+
|
|
582
|
+
Returns:
|
|
583
|
+
Texto sem colchetes, seguro para ``console.print``.
|
|
584
|
+
"""
|
|
585
|
+
key = self.name
|
|
586
|
+
try:
|
|
587
|
+
for ep in entry_points(group="quantilica.fetchers"):
|
|
588
|
+
if ep.dist is not None and ep.dist.name == self.name:
|
|
589
|
+
key = ep.name
|
|
590
|
+
break
|
|
591
|
+
except (
|
|
592
|
+
Exception
|
|
593
|
+
): # pragma: no cover - metadados quebrados não podem mascarar o erro
|
|
594
|
+
pass
|
|
595
|
+
return f"quantilica install {key} --extra analysis"
|
|
596
|
+
|
|
546
597
|
def command_convert(
|
|
547
598
|
self, func: Callable[[Path, Path], Any]
|
|
548
599
|
) -> Callable[[Path, Path], Any]:
|
|
@@ -586,7 +637,7 @@ class FetcherApp:
|
|
|
586
637
|
except ImportError:
|
|
587
638
|
console.print(
|
|
588
639
|
f"[red]Erro:[/red] convert requer extras de análise: "
|
|
589
|
-
|
|
640
|
+
f"{self._analysis_hint()}"
|
|
590
641
|
)
|
|
591
642
|
raise typer.Exit(1) from None
|
|
592
643
|
console.print(
|
|
@@ -639,7 +690,7 @@ class FetcherApp:
|
|
|
639
690
|
except ImportError:
|
|
640
691
|
console.print(
|
|
641
692
|
f"[red]Erro:[/red] archive requer extras de análise: "
|
|
642
|
-
|
|
693
|
+
f"{self._analysis_hint()}"
|
|
643
694
|
)
|
|
644
695
|
raise typer.Exit(1) from None
|
|
645
696
|
console.print(f"[green]✓[/green] Arquivo criado em [dim]{output}[/dim].")
|
|
@@ -738,7 +789,7 @@ class FetcherApp:
|
|
|
738
789
|
except ImportError:
|
|
739
790
|
console.print(
|
|
740
791
|
f"[red]Erro:[/red] pipeline (conversão) requer extras de "
|
|
741
|
-
|
|
792
|
+
f"análise: {self._analysis_hint()}"
|
|
742
793
|
)
|
|
743
794
|
raise typer.Exit(1) from None
|
|
744
795
|
console.print(
|
|
@@ -754,9 +805,17 @@ class FetcherApp:
|
|
|
754
805
|
return None
|
|
755
806
|
|
|
756
807
|
def _download_file(
|
|
757
|
-
self,
|
|
808
|
+
self,
|
|
809
|
+
url: str,
|
|
810
|
+
output: Path,
|
|
811
|
+
progress: ProgressCallback | None = None,
|
|
812
|
+
*,
|
|
813
|
+
force: bool = False,
|
|
758
814
|
) -> Path:
|
|
759
815
|
dataset_id = output.parent.name
|
|
816
|
+
# `force` só é repassado quando True: clients/subclasses antigos não
|
|
817
|
+
# precisam aceitar o kwarg.
|
|
818
|
+
extra: dict[str, Any] = {"force": True} if force else {}
|
|
760
819
|
return self.client.download_with_manifest(
|
|
761
820
|
url,
|
|
762
821
|
output,
|
|
@@ -764,6 +823,7 @@ class FetcherApp:
|
|
|
764
823
|
dataset_id=dataset_id,
|
|
765
824
|
producer=self.name,
|
|
766
825
|
progress=progress,
|
|
826
|
+
**extra,
|
|
767
827
|
)
|
|
768
828
|
|
|
769
829
|
def download_entry(
|
|
@@ -773,6 +833,7 @@ class FetcherApp:
|
|
|
773
833
|
*,
|
|
774
834
|
dry_run: bool = False,
|
|
775
835
|
progress: ProgressCallback | None = None,
|
|
836
|
+
force: bool = False,
|
|
776
837
|
) -> Path:
|
|
777
838
|
"""Download one dataset entry and return the destination path.
|
|
778
839
|
|
|
@@ -781,6 +842,7 @@ class FetcherApp:
|
|
|
781
842
|
output_dir: Destination directory.
|
|
782
843
|
dry_run: If True, computes the destination path without downloading.
|
|
783
844
|
progress: Optional callback to track download progress.
|
|
845
|
+
force: Se True, ignora a checagem de frescor local e baixa sempre.
|
|
784
846
|
|
|
785
847
|
Returns:
|
|
786
848
|
The local path where the file was (or would be) saved.
|
|
@@ -811,6 +873,10 @@ class FetcherApp:
|
|
|
811
873
|
|
|
812
874
|
if dry_run:
|
|
813
875
|
return output
|
|
876
|
+
if force:
|
|
877
|
+
return self._download_file(
|
|
878
|
+
url, output, progress=progress, force=True
|
|
879
|
+
)
|
|
814
880
|
return self._download_file(url, output, progress=progress)
|
|
815
881
|
except HttpStatusError as exc:
|
|
816
882
|
if exc.status_code == 404:
|
|
@@ -834,6 +900,8 @@ class FetcherApp:
|
|
|
834
900
|
entries: list[dict[str, Any]],
|
|
835
901
|
output_dir: Path,
|
|
836
902
|
workers: int = 4,
|
|
903
|
+
*,
|
|
904
|
+
force: bool = False,
|
|
837
905
|
) -> tuple[int, int, list[tuple[str, str]]]:
|
|
838
906
|
"""Executes parallel download for a list of dataset entries.
|
|
839
907
|
|
|
@@ -841,6 +909,7 @@ class FetcherApp:
|
|
|
841
909
|
entries: List of dataset entries to download.
|
|
842
910
|
output_dir: The target base directory for downloads.
|
|
843
911
|
workers: Maximum number of parallel downloads.
|
|
912
|
+
force: Se True, ignora a checagem de frescor local e baixa tudo.
|
|
844
913
|
|
|
845
914
|
Returns:
|
|
846
915
|
A tuple containing:
|
|
@@ -891,7 +960,10 @@ class FetcherApp:
|
|
|
891
960
|
try:
|
|
892
961
|
eid = entry.get("id", "unknown")
|
|
893
962
|
with pool.acquire(description=f"[cyan]{eid}[/cyan]") as cb:
|
|
894
|
-
|
|
963
|
+
if force:
|
|
964
|
+
self.download_entry(entry, output_dir, progress=cb, force=True)
|
|
965
|
+
else:
|
|
966
|
+
self.download_entry(entry, output_dir, progress=cb)
|
|
895
967
|
return True
|
|
896
968
|
except Exception as exc:
|
|
897
969
|
errors.append((entry.get("id", "unknown"), str(exc)))
|
|
@@ -946,14 +1018,24 @@ class FetcherApp:
|
|
|
946
1018
|
|
|
947
1019
|
head = None
|
|
948
1020
|
head_error: Exception | None = None
|
|
1021
|
+
not_found = 0
|
|
949
1022
|
for url in urls_to_try:
|
|
950
1023
|
try:
|
|
951
1024
|
candidate = self.client.head(url)
|
|
952
1025
|
except HttpStatusError as exc:
|
|
1026
|
+
head_error = exc
|
|
953
1027
|
if exc.status_code == 404:
|
|
954
|
-
|
|
1028
|
+
not_found += 1
|
|
955
1029
|
continue
|
|
956
|
-
|
|
1030
|
+
if exc.status_code in (403, 405):
|
|
1031
|
+
try:
|
|
1032
|
+
probed = self._range_probe(entry, output_dir, url, partition)
|
|
1033
|
+
except _ProbeNotFoundError:
|
|
1034
|
+
# HEAD recusado, mas o GET de probe prova que sumiu.
|
|
1035
|
+
not_found += 1
|
|
1036
|
+
continue
|
|
1037
|
+
if probed is not None:
|
|
1038
|
+
return probed
|
|
957
1039
|
break
|
|
958
1040
|
except Exception as exc: # noqa: BLE001 - metadata best effort
|
|
959
1041
|
head_error = exc
|
|
@@ -963,6 +1045,21 @@ class FetcherApp:
|
|
|
963
1045
|
|
|
964
1046
|
if head is None:
|
|
965
1047
|
local = self.path_builder(output_dir, entry, None)
|
|
1048
|
+
if not_found == len(urls_to_try):
|
|
1049
|
+
return CheckPlanItem(
|
|
1050
|
+
dataset=dataset,
|
|
1051
|
+
id=eid,
|
|
1052
|
+
url=str(entry["url"]),
|
|
1053
|
+
partition=partition,
|
|
1054
|
+
local_path=str(local),
|
|
1055
|
+
remote_etag=None,
|
|
1056
|
+
remote_last_modified=None,
|
|
1057
|
+
remote_size=None,
|
|
1058
|
+
local_exists=local.exists(),
|
|
1059
|
+
action="skip-unavailable",
|
|
1060
|
+
reason="remote-404",
|
|
1061
|
+
entry=dict(entry),
|
|
1062
|
+
)
|
|
966
1063
|
return CheckPlanItem(
|
|
967
1064
|
dataset=dataset,
|
|
968
1065
|
id=eid,
|
|
@@ -1011,6 +1108,106 @@ class FetcherApp:
|
|
|
1011
1108
|
entry=dict(entry),
|
|
1012
1109
|
)
|
|
1013
1110
|
|
|
1111
|
+
def entry_from_plan(self, data: dict[str, Any]) -> dict[str, Any]:
|
|
1112
|
+
"""Restaura uma entry desserializada de um :class:`CheckPlan`.
|
|
1113
|
+
|
|
1114
|
+
O padrão retorna uma cópia rasa de ``data``, sem nenhuma
|
|
1115
|
+
transformação. Sobrescreva (subclasse ou
|
|
1116
|
+
atribuição de função na instância, que recebe só ``data``) quando
|
|
1117
|
+
``check_entry`` serializa objetos ricos (ex.: ``datetime``) na entry.
|
|
1118
|
+
|
|
1119
|
+
Args:
|
|
1120
|
+
data: Entry como lida do plano JSON.
|
|
1121
|
+
|
|
1122
|
+
Returns:
|
|
1123
|
+
A entry pronta para ``download_entry``/``path_builder``.
|
|
1124
|
+
"""
|
|
1125
|
+
return dict(data)
|
|
1126
|
+
|
|
1127
|
+
def _range_probe(
|
|
1128
|
+
self,
|
|
1129
|
+
entry: dict[str, Any],
|
|
1130
|
+
output_dir: Path,
|
|
1131
|
+
url: str,
|
|
1132
|
+
partition: str | None,
|
|
1133
|
+
) -> CheckPlanItem | None:
|
|
1134
|
+
"""Probe ``GET`` com ``Range: bytes=0-0`` quando o ``HEAD`` é recusado.
|
|
1135
|
+
|
|
1136
|
+
Nunca lê o corpo: se o servidor ignorar o Range e responder 200, a
|
|
1137
|
+
resposta é fechada sem consumir o body.
|
|
1138
|
+
|
|
1139
|
+
Returns:
|
|
1140
|
+
O veredito da entrada, ou ``None`` se o client não suporta
|
|
1141
|
+
streaming ou o probe falhou (mantém o comportamento padrão).
|
|
1142
|
+
|
|
1143
|
+
Raises:
|
|
1144
|
+
_ProbeNotFoundError: Se o GET de probe responder 404.
|
|
1145
|
+
"""
|
|
1146
|
+
stream = getattr(self.client, "stream", None)
|
|
1147
|
+
if not callable(stream):
|
|
1148
|
+
return None
|
|
1149
|
+
try:
|
|
1150
|
+
with stream(
|
|
1151
|
+
"GET",
|
|
1152
|
+
url,
|
|
1153
|
+
# identity: Content-Length/Content-Range devem refletir o
|
|
1154
|
+
# tamanho real, não o comprimido.
|
|
1155
|
+
headers={"Range": "bytes=0-0", "Accept-Encoding": "identity"},
|
|
1156
|
+
) as response:
|
|
1157
|
+
headers = response.headers
|
|
1158
|
+
status = response.status_code
|
|
1159
|
+
final_url = str(response.url)
|
|
1160
|
+
except HttpStatusError as exc:
|
|
1161
|
+
if exc.status_code == 404:
|
|
1162
|
+
raise _ProbeNotFoundError(url) from exc
|
|
1163
|
+
logger.debug("[check] probe Range falhou para %s: %r", url, exc)
|
|
1164
|
+
return None
|
|
1165
|
+
except Exception as exc: # noqa: BLE001 - probe é best effort
|
|
1166
|
+
logger.debug("[check] probe Range falhou para %s: %r", url, exc)
|
|
1167
|
+
return None
|
|
1168
|
+
|
|
1169
|
+
total: int | None = None
|
|
1170
|
+
content_range = headers.get("Content-Range")
|
|
1171
|
+
if status == 206 and content_range and "/" in content_range:
|
|
1172
|
+
raw = content_range.rsplit("/", 1)[1].strip()
|
|
1173
|
+
total = int(raw) if raw.isdigit() else None
|
|
1174
|
+
elif status == 200:
|
|
1175
|
+
encoding = (headers.get("Content-Encoding") or "identity").strip()
|
|
1176
|
+
if encoding.lower() != "identity":
|
|
1177
|
+
return None # Content-Length seria o tamanho comprimido
|
|
1178
|
+
raw = (headers.get("Content-Length") or "").strip()
|
|
1179
|
+
total = int(raw) if raw.isdigit() else None
|
|
1180
|
+
if total is None:
|
|
1181
|
+
return None
|
|
1182
|
+
|
|
1183
|
+
last_modified = _parse_head_date(headers.get("Last-Modified"))
|
|
1184
|
+
local = self.path_builder(output_dir, entry, last_modified)
|
|
1185
|
+
sidecar = manifest_sidecar_path(local)
|
|
1186
|
+
local_exists = local.exists()
|
|
1187
|
+
exists = local_exists or sidecar.exists()
|
|
1188
|
+
action, reason = "download", "range-probe"
|
|
1189
|
+
if local_exists and local.stat().st_size == total:
|
|
1190
|
+
action, reason = "skip-up-to-date", "size-match-range-probe"
|
|
1191
|
+
elif sidecar.exists():
|
|
1192
|
+
with contextlib.suppress(OSError, ValueError, AttributeError):
|
|
1193
|
+
data = json.loads(sidecar.read_text(encoding="utf-8"))
|
|
1194
|
+
if data.get("size_bytes") == total:
|
|
1195
|
+
action, reason = "skip-up-to-date", "size-match-range-probe"
|
|
1196
|
+
return CheckPlanItem(
|
|
1197
|
+
dataset=str(entry.get("group") or entry.get("id") or "datasets"),
|
|
1198
|
+
id=str(entry.get("id", "unknown")),
|
|
1199
|
+
url=final_url,
|
|
1200
|
+
partition=partition,
|
|
1201
|
+
local_path=str(local),
|
|
1202
|
+
remote_etag=headers.get("ETag"),
|
|
1203
|
+
remote_last_modified=headers.get("Last-Modified"),
|
|
1204
|
+
remote_size=total,
|
|
1205
|
+
local_exists=exists,
|
|
1206
|
+
action=action,
|
|
1207
|
+
reason=reason,
|
|
1208
|
+
entry=dict(entry),
|
|
1209
|
+
)
|
|
1210
|
+
|
|
1014
1211
|
def check_datasets(
|
|
1015
1212
|
self,
|
|
1016
1213
|
entries: list[dict[str, Any]],
|
|
@@ -1083,6 +1280,14 @@ class FetcherApp:
|
|
|
1083
1280
|
"de grupos).",
|
|
1084
1281
|
),
|
|
1085
1282
|
] = None,
|
|
1283
|
+
force: Annotated[
|
|
1284
|
+
bool,
|
|
1285
|
+
typer.Option(
|
|
1286
|
+
"--force",
|
|
1287
|
+
help="Ignora a checagem de frescor local (arquivo/sidecar) "
|
|
1288
|
+
"e baixa todas as entradas selecionadas (útil com --from-plan)",
|
|
1289
|
+
),
|
|
1290
|
+
] = False,
|
|
1086
1291
|
verbose: Annotated[
|
|
1087
1292
|
bool, typer.Option("--verbose", help="Logs detalhados")
|
|
1088
1293
|
] = False,
|
|
@@ -1097,7 +1302,9 @@ class FetcherApp:
|
|
|
1097
1302
|
console.print(f"[red]Plano inválido: {exc}[/red]")
|
|
1098
1303
|
raise typer.Exit(1) from None
|
|
1099
1304
|
entries = [
|
|
1100
|
-
dict(item.entry)
|
|
1305
|
+
self.entry_from_plan(dict(item.entry))
|
|
1306
|
+
for item in plan.items
|
|
1307
|
+
if item.action == "download"
|
|
1101
1308
|
]
|
|
1102
1309
|
console.print(
|
|
1103
1310
|
f"[dim]Plano {from_plan}: {len(entries)} entrada(s) "
|
|
@@ -1121,9 +1328,14 @@ class FetcherApp:
|
|
|
1121
1328
|
console.print(f"\n[bold]{len(entries)}[/bold] arquivo(s) listado(s).")
|
|
1122
1329
|
return
|
|
1123
1330
|
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1331
|
+
if force:
|
|
1332
|
+
downloaded, total, errors = self.download_datasets(
|
|
1333
|
+
entries, actual_output, workers=workers, force=True
|
|
1334
|
+
)
|
|
1335
|
+
else:
|
|
1336
|
+
downloaded, total, errors = self.download_datasets(
|
|
1337
|
+
entries, actual_output, workers=workers
|
|
1338
|
+
)
|
|
1127
1339
|
|
|
1128
1340
|
console.print(
|
|
1129
1341
|
f"\n[green]Concluído:[/green] {downloaded}/{total} arquivo(s) baixado(s)."
|
|
@@ -1154,6 +1366,14 @@ class FetcherApp:
|
|
|
1154
1366
|
help="Imprime o plano em JSON (para 'sync --from-plan')",
|
|
1155
1367
|
),
|
|
1156
1368
|
] = False,
|
|
1369
|
+
out: Annotated[
|
|
1370
|
+
Path | None,
|
|
1371
|
+
typer.Option(
|
|
1372
|
+
"--out",
|
|
1373
|
+
help="Grava o plano JSON neste arquivo (atômico) e imprime "
|
|
1374
|
+
"só um resumo; prevalece sobre --json",
|
|
1375
|
+
),
|
|
1376
|
+
] = None,
|
|
1157
1377
|
verbose: Annotated[
|
|
1158
1378
|
bool, typer.Option("--verbose", help="Logs detalhados")
|
|
1159
1379
|
] = False,
|
|
@@ -1171,7 +1391,20 @@ class FetcherApp:
|
|
|
1171
1391
|
entries = [e for g in target_groups for e in self.list_datasets(g)]
|
|
1172
1392
|
plan = self.check_datasets(entries, actual_output, workers=workers)
|
|
1173
1393
|
|
|
1174
|
-
if
|
|
1394
|
+
if out is not None:
|
|
1395
|
+
tmp = out.with_name(f".{out.name}.{os.getpid()}.tmp")
|
|
1396
|
+
try:
|
|
1397
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
1398
|
+
try:
|
|
1399
|
+
tmp.write_text(plan.to_json(), encoding="utf-8")
|
|
1400
|
+
os.replace(tmp, out)
|
|
1401
|
+
finally:
|
|
1402
|
+
tmp.unlink(missing_ok=True)
|
|
1403
|
+
except OSError as exc:
|
|
1404
|
+
console.print(f"[red]Falha ao gravar o plano em {out}: {exc}[/red]")
|
|
1405
|
+
raise typer.Exit(1) from None
|
|
1406
|
+
console.print(f"{plan.summary_line()} Plano gravado em {out}")
|
|
1407
|
+
elif as_json:
|
|
1175
1408
|
print(plan.to_json())
|
|
1176
1409
|
else:
|
|
1177
1410
|
plan.render_table()
|
|
@@ -1193,17 +1426,9 @@ class FetcherApp:
|
|
|
1193
1426
|
title=f"[bold]{group_id}[/bold] — {group_info.get('name', '')}",
|
|
1194
1427
|
)
|
|
1195
1428
|
for entry in self.list_datasets(group_id):
|
|
1196
|
-
if entry.get("semester") is not None:
|
|
1197
|
-
partition = f"{entry['year']}-S{entry['semester']}"
|
|
1198
|
-
elif entry.get("month") is not None:
|
|
1199
|
-
partition = f"{entry['year']}-{entry['month']:02d}"
|
|
1200
|
-
elif entry.get("year") is not None:
|
|
1201
|
-
partition = str(entry["year"])
|
|
1202
|
-
else:
|
|
1203
|
-
partition = "—"
|
|
1204
1429
|
table.add_row(
|
|
1205
1430
|
entry.get("id", ""),
|
|
1206
|
-
|
|
1431
|
+
_partition_label(entry) or "—",
|
|
1207
1432
|
entry.get("ext", ""),
|
|
1208
1433
|
entry.get("url", ""),
|
|
1209
1434
|
)
|