quantilica-cli 0.11.0__tar.gz → 0.12.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 (28) hide show
  1. {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/CHANGELOG.md +11 -0
  2. {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/PKG-INFO +1 -1
  3. {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/pyproject.toml +1 -1
  4. {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/src/quantilica/cli/sdk.py +92 -22
  5. {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/tests/test_sdk.py +83 -0
  6. {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/tests/test_sdk_force_probe.py +89 -0
  7. {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/.githooks/pre-commit +0 -0
  8. {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/.githooks/pre-push +0 -0
  9. {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/.github/workflows/publish.yml +0 -0
  10. {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/.github/workflows/test.yml +0 -0
  11. {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/.gitignore +0 -0
  12. {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/LICENSE +0 -0
  13. {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/README.md +0 -0
  14. {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/src/quantilica/cli/__init__.py +0 -0
  15. {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/src/quantilica/cli/cli.py +0 -0
  16. {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/src/quantilica/cli/health.py +0 -0
  17. {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/src/quantilica/cli/manifests.py +0 -0
  18. {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/src/quantilica/cli/sources.py +0 -0
  19. {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/src/quantilica/cli/ui.py +0 -0
  20. {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/src/quantilica/py.typed +0 -0
  21. {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/tests/__init__.py +0 -0
  22. {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/tests/test_health.py +0 -0
  23. {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/tests/test_manifests.py +0 -0
  24. {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/tests/test_package_hygiene.py +0 -0
  25. {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/tests/test_sdk_mes_string.py +0 -0
  26. {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/tests/test_sdk_onda_a2.py +0 -0
  27. {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/tests/test_sources.py +0 -0
  28. {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/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.12.0] - 2026-10-19
9
+
10
+ ### Adicionado
11
+
12
+ - `FetcherApp(source_id=...)`: `source_id` explícito, gravado nos manifests de download. Sem ele, continua derivado de `name` sem o sufixo `-fetcher`, e o `default_output` padrão passa a ser `/data/{source_id}`.
13
+
14
+ ### Corrigido
15
+
16
+ - `check`: o probe `GET Range` tenta de novo (com backoff) em 429/5xx e em erros de transporte (`DEFAULT_RETRY_EXCEPTIONS`: timeout, reset de conexão) antes de desistir. Antes, uma falha transitória virava 404 e a URL saía como indisponível. Quando o probe desiste, a causa é logada em `WARNING` (`[check] probe Range falhou para <url> após N tentativa(s): ...`), e não mais em `DEBUG`.
17
+ - `check`: uma exceção inesperada em uma entrada (por exemplo, `OSError` ao montar o caminho local) não aborta mais o `check` inteiro com código 1. A entrada é verificada de novo uma vez e, se falhar outra vez, vira `download` com `reason=check-error: ...`, e o plano segue cobrindo as demais.
18
+
8
19
  ## [0.11.0] - 2026-10-14
9
20
 
10
21
  ### Adicionado
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: quantilica-cli
3
- Version: 0.11.0
3
+ Version: 0.12.0
4
4
  Summary: Unified CLI for Quantilica open data fetchers
5
5
  Author-email: "Komesu, D.K." <daniel@dkko.me>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "quantilica-cli"
3
- version = "0.11.0"
3
+ version = "0.12.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" }]
@@ -12,6 +12,7 @@ import email.utils
12
12
  import json
13
13
  import os
14
14
  import threading
15
+ import time
15
16
  from collections.abc import Callable, Iterable
16
17
  from dataclasses import dataclass, field
17
18
  from importlib.metadata import entry_points
@@ -22,6 +23,7 @@ import typer
22
23
  from quantilica.core.exceptions import FetchError
23
24
  from quantilica.core.ftp import FtpClient
24
25
  from quantilica.core.http import (
26
+ DEFAULT_RETRY_EXCEPTIONS,
25
27
  HttpClient,
26
28
  HttpStatusError,
27
29
  ProgressCallback,
@@ -165,6 +167,13 @@ def _short_remote(item: CheckPlanItem) -> str:
165
167
  return remote
166
168
 
167
169
 
170
+ # Probe Range: novas tentativas em status ou erro de transporte transitório
171
+ # (``DEFAULT_RETRY_EXCEPTIONS``) antes de desistir.
172
+ _PROBE_RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
173
+ _PROBE_ATTEMPTS = 3
174
+ _PROBE_BACKOFF_S = 1.0
175
+
176
+
168
177
  class _ProbeNotFoundError(Exception):
169
178
  """O GET de probe Range respondeu 404 (URL removida no servidor)."""
170
179
 
@@ -513,6 +522,9 @@ class FetcherApp:
513
522
  :func:`default_path_builder` (canonical ``DataRepository`` layout).
514
523
  default_output: Default output directory path.
515
524
  client: HTTP or FTP client instance. Defaults to default_client().
525
+ source_id: Source ID recorded in download manifests. Defaults to
526
+ ``name`` without the ``-fetcher`` suffix. When ``default_output``
527
+ is omitted it is derived as ``/data/{source_id}``.
516
528
  build_default_commands: If True (default), registers the built-in
517
529
  ``sync`` and ``list`` commands. Set to False to register only
518
530
  custom commands via :meth:`attach_command`.
@@ -530,6 +542,7 @@ class FetcherApp:
530
542
  path_builder: Callable[[Path, dict[str, Any], dt.date | None], Path]
531
543
  | None = None,
532
544
  default_output: Path | None = None,
545
+ source_id: str | None = None,
533
546
  client: HttpClient | FtpClient | None = None,
534
547
  build_default_commands: bool = True,
535
548
  ):
@@ -539,9 +552,10 @@ class FetcherApp:
539
552
  self.aliases = aliases_dict
540
553
  self.list_datasets = list_datasets
541
554
  self.path_builder = path_builder or default_path_builder
542
- self.default_output = default_output or Path(
543
- f"/data/{name.replace('-fetcher', '')}"
544
- )
555
+ # source_id explícito vence; senão deriva de `name` sem o sufixo
556
+ # "-fetcher" (comportamento histórico).
557
+ self.source_id = source_id or name.replace("-fetcher", "")
558
+ self.default_output = default_output or Path(f"/data/{self.source_id}")
545
559
  self.client = client or default_client()
546
560
 
547
561
  self.all_group_keys = list(self.groups.keys())
@@ -819,7 +833,7 @@ class FetcherApp:
819
833
  return self.client.download_with_manifest(
820
834
  url,
821
835
  output,
822
- source_id=self.name.replace("-fetcher", ""),
836
+ source_id=self.source_id,
823
837
  dataset_id=dataset_id,
824
838
  producer=self.name,
825
839
  progress=progress,
@@ -1146,24 +1160,41 @@ class FetcherApp:
1146
1160
  stream = getattr(self.client, "stream", None)
1147
1161
  if not callable(stream):
1148
1162
  return None
1149
- try:
1150
- with stream(
1151
- "GET",
1163
+ for attempt in range(1, _PROBE_ATTEMPTS + 1):
1164
+ try:
1165
+ with stream(
1166
+ "GET",
1167
+ url,
1168
+ # identity: Content-Length/Content-Range devem refletir o
1169
+ # tamanho real, não o comprimido.
1170
+ headers={"Range": "bytes=0-0", "Accept-Encoding": "identity"},
1171
+ ) as response:
1172
+ headers = response.headers
1173
+ status = response.status_code
1174
+ final_url = str(response.url)
1175
+ break
1176
+ except HttpStatusError as exc:
1177
+ if exc.status_code == 404:
1178
+ raise _ProbeNotFoundError(url) from exc
1179
+ failure: Exception = exc
1180
+ retryable = exc.status_code in _PROBE_RETRY_STATUSES
1181
+ except DEFAULT_RETRY_EXCEPTIONS as exc:
1182
+ failure, retryable = exc, True
1183
+ except Exception as exc: # noqa: BLE001 - probe é best effort
1184
+ failure, retryable = exc, False
1185
+ # 429/5xx ou erro de transporte transitório (ex.: 503 intermitente
1186
+ # do gov.br): sem nova tentativa, uma URL removida viraria
1187
+ # "download" e falharia com 404 no sync.
1188
+ if retryable and attempt < _PROBE_ATTEMPTS:
1189
+ time.sleep(_PROBE_BACKOFF_S * attempt)
1190
+ continue
1191
+ # WARNING: o cron não roda com --verbose e precisa registrar a causa.
1192
+ logger.warning(
1193
+ "[check] probe Range falhou para %s após %d tentativa(s): %r",
1152
1194
  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)
1195
+ attempt,
1196
+ failure,
1197
+ )
1167
1198
  return None
1168
1199
 
1169
1200
  total: int | None = None
@@ -1208,6 +1239,45 @@ class FetcherApp:
1208
1239
  entry=dict(entry),
1209
1240
  )
1210
1241
 
1242
+ def _check_entry_isolated(
1243
+ self, entry: dict[str, Any], output_dir: Path
1244
+ ) -> CheckPlanItem:
1245
+ """Run :meth:`check_entry` without letting one entry abort the check.
1246
+
1247
+ An unexpected error (e.g. ``OSError`` while building the local path)
1248
+ is retried once; if it persists the entry becomes a conservative
1249
+ ``download`` verdict, so the plan still covers every other entry
1250
+ instead of the whole ``check`` exiting 1.
1251
+ """
1252
+ for attempt in (1, 2):
1253
+ try:
1254
+ return self.check_entry(entry, output_dir)
1255
+ except Exception as exc: # noqa: BLE001 - isolate per entry
1256
+ failure = exc
1257
+ if attempt == 1:
1258
+ time.sleep(1.0)
1259
+ eid = str(entry.get("id", "unknown"))
1260
+ logger.warning(
1261
+ "[check] falha ao verificar %s (%s: %s); marcada para download",
1262
+ eid,
1263
+ type(failure).__name__,
1264
+ failure,
1265
+ )
1266
+ return CheckPlanItem(
1267
+ dataset=str(entry.get("group") or entry.get("id") or "datasets"),
1268
+ id=eid,
1269
+ url=str(entry.get("url", "")),
1270
+ partition=_partition_label(entry),
1271
+ local_path="",
1272
+ remote_etag=None,
1273
+ remote_last_modified=None,
1274
+ remote_size=None,
1275
+ local_exists=False,
1276
+ action="download",
1277
+ reason=f"check-error: {type(failure).__name__}: {failure}",
1278
+ entry=dict(entry),
1279
+ )
1280
+
1211
1281
  def check_datasets(
1212
1282
  self,
1213
1283
  entries: list[dict[str, Any]],
@@ -1227,7 +1297,7 @@ class FetcherApp:
1227
1297
  results: list[CheckPlanItem | None] = [None] * len(entries)
1228
1298
 
1229
1299
  def _worker(index: int, entry: dict[str, Any]) -> None:
1230
- results[index] = self.check_entry(entry, output_dir)
1300
+ results[index] = self._check_entry_isolated(entry, output_dir)
1231
1301
  if (index + 1) % 100 == 0 or index + 1 == len(entries):
1232
1302
  logger.info("[check] %d/%d verificados", index + 1, len(entries))
1233
1303
 
@@ -497,3 +497,86 @@ def test_check_entry_ephemeral_stale_downloads(tmp_path: Path):
497
497
  item = app.check_entry(entry, tmp_path)
498
498
  assert item.action == "download"
499
499
  assert item.reason == "remote-newer"
500
+
501
+
502
+ def test_source_id_defaults_to_name_without_suffix():
503
+ app = make_app()
504
+ assert app.source_id == "comex"
505
+ assert app.default_output == Path("/data/comex")
506
+
507
+
508
+ def test_explicit_source_id_derives_default_output():
509
+ app = make_app(source_id="custom")
510
+ assert app.source_id == "custom"
511
+ assert app.default_output == Path("/data/custom")
512
+
513
+
514
+ def test_explicit_default_output_wins_over_source_id(tmp_path: Path):
515
+ app = make_app(source_id="custom", default_output=tmp_path)
516
+ assert app.default_output == tmp_path
517
+
518
+
519
+ def test_check_datasets_isolates_entry_failure(tmp_path: Path, monkeypatch):
520
+ """Uma entrada que levanta (ex.: OSError) não derruba o check inteiro."""
521
+ monkeypatch.setattr("quantilica.cli.sdk.time.sleep", lambda _s: None)
522
+ app = make_app(
523
+ client=HttpClient(
524
+ attempts=1,
525
+ transport=httpx2.MockTransport(lambda r: httpx2.Response(200)),
526
+ )
527
+ )
528
+ entries = sample_list_datasets("exp")
529
+ calls: dict[str, int] = {}
530
+ real = app.check_entry
531
+
532
+ def flaky(entry, output_dir):
533
+ key = str(entry["id"])
534
+ calls[key] = calls.get(key, 0) + 1
535
+ if key == str(entries[0]["id"]):
536
+ raise FileExistsError(17, "File exists", "/data")
537
+ return real(entry, output_dir)
538
+
539
+ monkeypatch.setattr(app, "check_entry", flaky)
540
+ plan = app.check_datasets(entries, tmp_path)
541
+ assert len(plan.items) == len(entries)
542
+ bad = next(i for i in plan.items if i.id == str(entries[0]["id"]))
543
+ assert bad.action == "download"
544
+ assert bad.reason.startswith("check-error: FileExistsError")
545
+ assert calls[str(entries[0]["id"])] == 2 # uma nova tentativa
546
+
547
+
548
+ def test_check_datasets_retry_recovers_transient_failure(tmp_path: Path, monkeypatch):
549
+ monkeypatch.setattr("quantilica.cli.sdk.time.sleep", lambda _s: None)
550
+ app = make_app(
551
+ client=HttpClient(
552
+ attempts=1,
553
+ transport=httpx2.MockTransport(lambda r: httpx2.Response(200)),
554
+ )
555
+ )
556
+ entries = sample_list_datasets("exp")[:1]
557
+ real = app.check_entry
558
+ state = {"n": 0}
559
+
560
+ def once(entry, output_dir):
561
+ state["n"] += 1
562
+ if state["n"] == 1:
563
+ raise OSError("transiente")
564
+ return real(entry, output_dir)
565
+
566
+ monkeypatch.setattr(app, "check_entry", once)
567
+ plan = app.check_datasets(entries, tmp_path)
568
+ assert plan.items[0].reason != "" and not plan.items[0].reason.startswith(
569
+ "check-error"
570
+ )
571
+
572
+
573
+ def test_check_entry_403_head_without_probe_support_downloads(tmp_path: Path):
574
+ """HEAD 403 e probe Range 403 → download (nunca exceção/exit 1)."""
575
+ app = make_app(
576
+ client=HttpClient(
577
+ attempts=1,
578
+ transport=httpx2.MockTransport(lambda r: httpx2.Response(403)),
579
+ )
580
+ )
581
+ item = app.check_entry(sample_list_datasets("exp")[0], tmp_path)
582
+ assert item.action == "download"
@@ -485,3 +485,92 @@ def test_head_403_probe_500_stays_download(tmp_path: Path):
485
485
  item = _probe_app(handler).check_entry(ENTRY, tmp_path)
486
486
  assert item.action == "download"
487
487
  assert item.reason.startswith("metadata-unavailable")
488
+
489
+
490
+ @pytest.mark.parametrize(
491
+ ("statuses", "action", "reason"),
492
+ [
493
+ # 503 transitório e depois 404 → removida de fato
494
+ ([503, 404], "skip-unavailable", "remote-404"),
495
+ # 429 e depois 206 → metadados pelo Range
496
+ ([429, 206], "download", "range-probe"),
497
+ # 503 persistente → desiste após as tentativas
498
+ ([503, 503, 503], "download", "metadata-unavailable"),
499
+ # 403 no GET não é transitório → sem nova tentativa
500
+ ([403], "download", "metadata-unavailable"),
501
+ ],
502
+ )
503
+ def test_probe_retries_transient_status(
504
+ tmp_path: Path, monkeypatch, statuses, action, reason
505
+ ):
506
+ from quantilica.cli import sdk
507
+
508
+ monkeypatch.setattr(sdk, "_PROBE_BACKOFF_S", 0.0)
509
+ gets: list[int] = []
510
+
511
+ def handler(request):
512
+ if request.method == "HEAD":
513
+ return httpx2.Response(403)
514
+ status = statuses[min(len(gets), len(statuses) - 1)]
515
+ gets.append(status)
516
+ headers = {"Content-Range": "bytes 0-0/10"} if status == 206 else {}
517
+ return httpx2.Response(status, headers=headers)
518
+
519
+ item = _probe_app(handler).check_entry(ENTRY, tmp_path)
520
+ assert item.action == action
521
+ assert item.reason.startswith(reason)
522
+ assert gets == statuses
523
+
524
+
525
+ @pytest.mark.parametrize(
526
+ ("failures", "action", "reason"),
527
+ [
528
+ # timeout transitório e depois 404 → removida de fato
529
+ ([httpx2.ReadTimeout], "skip-unavailable", "remote-404"),
530
+ # conexão derrubada e depois 206 → metadados pelo Range
531
+ ([httpx2.RemoteProtocolError], "download", "range-probe"),
532
+ # erro de transporte persistente → desiste após as tentativas
533
+ ([httpx2.ConnectError] * 3, "download", "metadata-unavailable"),
534
+ ],
535
+ )
536
+ def test_probe_retries_transport_errors(
537
+ tmp_path: Path, monkeypatch, failures, action, reason
538
+ ):
539
+ from quantilica.cli import sdk
540
+
541
+ monkeypatch.setattr(sdk, "_PROBE_BACKOFF_S", 0.0)
542
+ gets: list[int] = []
543
+
544
+ def handler(request):
545
+ if request.method == "HEAD":
546
+ return httpx2.Response(403)
547
+ gets.append(1)
548
+ if len(gets) <= len(failures):
549
+ raise failures[len(gets) - 1]("transitório", request=request)
550
+ if reason == "remote-404":
551
+ return httpx2.Response(404)
552
+ return httpx2.Response(206, headers={"Content-Range": "bytes 0-0/10"})
553
+
554
+ item = _probe_app(handler).check_entry(ENTRY, tmp_path)
555
+ assert item.action == action
556
+ assert item.reason.startswith(reason)
557
+ assert len(gets) == min(len(failures) + 1, 3)
558
+
559
+
560
+ def test_probe_failure_logs_warning_with_cause(tmp_path: Path, monkeypatch):
561
+ from quantilica.cli import sdk
562
+
563
+ monkeypatch.setattr(sdk, "_PROBE_BACKOFF_S", 0.0)
564
+ warnings: list[str] = []
565
+ monkeypatch.setattr(
566
+ sdk.logger, "warning", lambda msg, *args: warnings.append(msg % args)
567
+ )
568
+
569
+ def handler(request):
570
+ return httpx2.Response(403 if request.method == "HEAD" else 503)
571
+
572
+ item = _probe_app(handler).check_entry(ENTRY, tmp_path)
573
+ assert item.reason.startswith("metadata-unavailable")
574
+ assert len(warnings) == 1
575
+ assert "após 3 tentativa(s)" in warnings[0]
576
+ assert "503" in warnings[0]
File without changes