quantilica-core 0.3.1__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 (39) hide show
  1. quantilica_core-0.3.1/.github/workflows/publish.yml +66 -0
  2. quantilica_core-0.3.1/.github/workflows/test.yml +38 -0
  3. quantilica_core-0.3.1/.gitignore +38 -0
  4. quantilica_core-0.3.1/CHANGELOG.md +17 -0
  5. quantilica_core-0.3.1/LICENSE +21 -0
  6. quantilica_core-0.3.1/PKG-INFO +103 -0
  7. quantilica_core-0.3.1/README.md +75 -0
  8. quantilica_core-0.3.1/pyproject.toml +67 -0
  9. quantilica_core-0.3.1/src/quantilica/core/__init__.py +10 -0
  10. quantilica_core-0.3.1/src/quantilica/core/cache.py +124 -0
  11. quantilica_core-0.3.1/src/quantilica/core/cli.py +109 -0
  12. quantilica_core-0.3.1/src/quantilica/core/config.py +97 -0
  13. quantilica_core-0.3.1/src/quantilica/core/dates.py +61 -0
  14. quantilica_core-0.3.1/src/quantilica/core/exceptions.py +29 -0
  15. quantilica_core-0.3.1/src/quantilica/core/fetcher.py +157 -0
  16. quantilica_core-0.3.1/src/quantilica/core/files.py +165 -0
  17. quantilica_core-0.3.1/src/quantilica/core/ftp.py +287 -0
  18. quantilica_core-0.3.1/src/quantilica/core/http.py +909 -0
  19. quantilica_core-0.3.1/src/quantilica/core/logging.py +90 -0
  20. quantilica_core-0.3.1/src/quantilica/core/manifests.py +335 -0
  21. quantilica_core-0.3.1/src/quantilica/core/metadata.py +316 -0
  22. quantilica_core-0.3.1/src/quantilica/core/platform.py +210 -0
  23. quantilica_core-0.3.1/src/quantilica/core/progress.py +61 -0
  24. quantilica_core-0.3.1/src/quantilica/core/py.typed +1 -0
  25. quantilica_core-0.3.1/src/quantilica/core/retry.py +163 -0
  26. quantilica_core-0.3.1/src/quantilica/core/storage.py +256 -0
  27. quantilica_core-0.3.1/tests/test_cache.py +60 -0
  28. quantilica_core-0.3.1/tests/test_cli.py +47 -0
  29. quantilica_core-0.3.1/tests/test_config.py +81 -0
  30. quantilica_core-0.3.1/tests/test_dates.py +71 -0
  31. quantilica_core-0.3.1/tests/test_fetcher.py +141 -0
  32. quantilica_core-0.3.1/tests/test_files.py +74 -0
  33. quantilica_core-0.3.1/tests/test_http.py +334 -0
  34. quantilica_core-0.3.1/tests/test_logging.py +93 -0
  35. quantilica_core-0.3.1/tests/test_manifests.py +176 -0
  36. quantilica_core-0.3.1/tests/test_metadata.py +197 -0
  37. quantilica_core-0.3.1/tests/test_platform.py +154 -0
  38. quantilica_core-0.3.1/tests/test_retry.py +64 -0
  39. quantilica_core-0.3.1/tests/test_storage.py +162 -0
@@ -0,0 +1,66 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ build:
10
+ name: Build distribution
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+
15
+ - name: Install uv
16
+ uses: astral-sh/setup-uv@v5
17
+
18
+ - name: Build sdist and wheel
19
+ run: uv build
20
+
21
+ - name: Upload build artifacts
22
+ uses: actions/upload-artifact@v4
23
+ with:
24
+ name: dist
25
+ path: dist/
26
+
27
+ publish-testpypi:
28
+ name: Publish to TestPyPI
29
+ needs: build
30
+ runs-on: ubuntu-latest
31
+ environment:
32
+ name: testpypi
33
+ url: https://test.pypi.org/p/quantilica-core
34
+ permissions:
35
+ id-token: write
36
+ steps:
37
+ - name: Download build artifacts
38
+ uses: actions/download-artifact@v4
39
+ with:
40
+ name: dist
41
+ path: dist/
42
+
43
+ - name: Publish to TestPyPI
44
+ uses: pypa/gh-action-pypi-publish@release/v1
45
+ with:
46
+ repository-url: https://test.pypi.org/legacy/
47
+ skip-existing: true
48
+
49
+ publish-pypi:
50
+ name: Publish to PyPI
51
+ needs: publish-testpypi
52
+ runs-on: ubuntu-latest
53
+ environment:
54
+ name: pypi
55
+ url: https://pypi.org/p/quantilica-core
56
+ permissions:
57
+ id-token: write
58
+ steps:
59
+ - name: Download build artifacts
60
+ uses: actions/download-artifact@v4
61
+ with:
62
+ name: dist
63
+ path: dist/
64
+
65
+ - name: Publish to PyPI
66
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,38 @@
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
@@ -0,0 +1,38 @@
1
+ # Build / packaging
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+
8
+ # Virtual envs e caches do uv
9
+ .venv/
10
+ .uv-cache/
11
+
12
+ # Lockfile — libs não versionam uv.lock
13
+ uv.lock
14
+
15
+ # Caches de teste / lint / tipos
16
+ .pytest_cache/
17
+ .ruff_cache/
18
+ .mypy_cache/
19
+
20
+ # Cobertura
21
+ .coverage
22
+ .coverage.*
23
+ htmlcov/
24
+
25
+ # Editor / IDE / OS
26
+ .vscode/
27
+ .idea/
28
+ .DS_Store
29
+ Thumbs.db
30
+ *.swp
31
+
32
+ # Logs e env locais
33
+ *.log
34
+ .env
35
+ .env.local
36
+
37
+ # Claude
38
+ .claude/
@@ -0,0 +1,17 @@
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-07-16
9
+
10
+ ### Corrigido
11
+
12
+ - Exemplos de import no README (namespace package `quantilica.core.*`, não `quantilica_core.*`)
13
+ - Instrução de instalação no README (`pip install quantilica-core`, em vez de git+https)
14
+
15
+ ### Adicionado
16
+
17
+ - Primeiro release público no PyPI
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Quantilica
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: quantilica-core
3
+ Version: 0.3.1
4
+ Summary: Common foundation utilities for Quantilica data projects
5
+ Project-URL: Homepage, https://github.com/Quantilica/quantilica-core
6
+ Project-URL: Repository, https://github.com/Quantilica/quantilica-core
7
+ Project-URL: Issues, https://github.com/Quantilica/quantilica-core/issues
8
+ Author-email: "Komesu, D.K." <daniel@dkko.me>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: brazil,data,open-data,quantilica
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.12
22
+ Requires-Dist: httpx>=0.28.1
23
+ Requires-Dist: tqdm>=4.67.1
24
+ Provides-Extra: cli
25
+ Requires-Dist: rich>=13.0.0; extra == 'cli'
26
+ Requires-Dist: typer>=0.15.0; extra == 'cli'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # quantilica-core: Fundação de infraestrutura para projetos Quantilica
30
+
31
+ ![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square) ![Python](https://img.shields.io/badge/python-3.12+-blue.svg?style=flat-square)
32
+
33
+ Biblioteca de utilitários domain-neutral que serve como base para todos os coletores e pipelines Quantilica. Centraliza rede resiliente, armazenamento atômico, proveniência de dados e logging estruturado — permitindo que cada pacote de domínio foque exclusivamente na lógica de sua fonte de dados.
34
+
35
+ ## Instalação
36
+
37
+ ```bash
38
+ pip install quantilica-core
39
+ ```
40
+
41
+ Com uv:
42
+
43
+ ```bash
44
+ uv add quantilica-core
45
+ ```
46
+
47
+ ## Uso Rápido
48
+
49
+ ```python
50
+ from quantilica.core.http import HttpClient
51
+ from quantilica.core.storage import LocalStorage
52
+ from quantilica.core.manifests import DownloadManifest
53
+
54
+ # Cliente HTTP com retry automático
55
+ client = HttpClient(attempts=3)
56
+ response = client.get("https://api.ibge.gov.br/...")
57
+
58
+ # Escrita atômica em disco
59
+ storage = LocalStorage("dados/raw")
60
+ stat = storage.write_bytes("sidra/tabela.csv", response.content)
61
+
62
+ # Registro de proveniência (SHA-256)
63
+ manifest = DownloadManifest.from_file(
64
+ source_id="ibge",
65
+ dataset_id="sidra-1234",
66
+ url="https://...",
67
+ file_path=stat.path,
68
+ producer="sidra-fetcher",
69
+ )
70
+ manifest.write_json(stat.path.with_suffix(".manifest.json"))
71
+ ```
72
+
73
+ ## Módulos
74
+
75
+ | Módulo | Descrição |
76
+ | :--- | :--- |
77
+ | `http` | `HttpClient` e `AsyncHttpClient` com backoff exponencial e jitter |
78
+ | `retry` | Lógica de retry configurável para falhas de rede e erros transientes |
79
+ | `storage` | `LocalStorage` para gerenciar artefatos brutos e processados atomicamente |
80
+ | `manifests` | `DownloadManifest` e `ExecutionManifest` para rastreabilidade completa |
81
+ | `metadata` | Modelos `MetadataCatalog`, `Source`, `Dataset` para interoperabilidade |
82
+ | `logging` | Logging estruturado via `get_logger` e `log_step` |
83
+ | `exceptions` | Hierarquia padrão: `FetchError`, `ParseError`, `StorageError` |
84
+
85
+ ## Princípios de Design
86
+
87
+ 1. **Neutralidade de domínio** — o core nunca sabe o que é IBGE, DATASUS ou INMET.
88
+ 2. **Leveza** — dependências mínimas no núcleo; integrações pesadas são extras opcionais.
89
+ 3. **Estabilidade** — alta cobertura de testes em todos os componentes de infraestrutura.
90
+ 4. **DX** — APIs tipadas e tratamento de erros consistente em toda a organização.
91
+
92
+ ## Desenvolvimento
93
+
94
+ ```bash
95
+ git clone https://github.com/Quantilica/quantilica-core.git
96
+ cd quantilica-core
97
+ uv sync --dev
98
+ uv run pytest
99
+ ```
100
+
101
+ ## Licença
102
+
103
+ MIT — veja [LICENSE](LICENSE).
@@ -0,0 +1,75 @@
1
+ # quantilica-core: Fundação de infraestrutura para projetos Quantilica
2
+
3
+ ![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square) ![Python](https://img.shields.io/badge/python-3.12+-blue.svg?style=flat-square)
4
+
5
+ Biblioteca de utilitários domain-neutral que serve como base para todos os coletores e pipelines Quantilica. Centraliza rede resiliente, armazenamento atômico, proveniência de dados e logging estruturado — permitindo que cada pacote de domínio foque exclusivamente na lógica de sua fonte de dados.
6
+
7
+ ## Instalação
8
+
9
+ ```bash
10
+ pip install quantilica-core
11
+ ```
12
+
13
+ Com uv:
14
+
15
+ ```bash
16
+ uv add quantilica-core
17
+ ```
18
+
19
+ ## Uso Rápido
20
+
21
+ ```python
22
+ from quantilica.core.http import HttpClient
23
+ from quantilica.core.storage import LocalStorage
24
+ from quantilica.core.manifests import DownloadManifest
25
+
26
+ # Cliente HTTP com retry automático
27
+ client = HttpClient(attempts=3)
28
+ response = client.get("https://api.ibge.gov.br/...")
29
+
30
+ # Escrita atômica em disco
31
+ storage = LocalStorage("dados/raw")
32
+ stat = storage.write_bytes("sidra/tabela.csv", response.content)
33
+
34
+ # Registro de proveniência (SHA-256)
35
+ manifest = DownloadManifest.from_file(
36
+ source_id="ibge",
37
+ dataset_id="sidra-1234",
38
+ url="https://...",
39
+ file_path=stat.path,
40
+ producer="sidra-fetcher",
41
+ )
42
+ manifest.write_json(stat.path.with_suffix(".manifest.json"))
43
+ ```
44
+
45
+ ## Módulos
46
+
47
+ | Módulo | Descrição |
48
+ | :--- | :--- |
49
+ | `http` | `HttpClient` e `AsyncHttpClient` com backoff exponencial e jitter |
50
+ | `retry` | Lógica de retry configurável para falhas de rede e erros transientes |
51
+ | `storage` | `LocalStorage` para gerenciar artefatos brutos e processados atomicamente |
52
+ | `manifests` | `DownloadManifest` e `ExecutionManifest` para rastreabilidade completa |
53
+ | `metadata` | Modelos `MetadataCatalog`, `Source`, `Dataset` para interoperabilidade |
54
+ | `logging` | Logging estruturado via `get_logger` e `log_step` |
55
+ | `exceptions` | Hierarquia padrão: `FetchError`, `ParseError`, `StorageError` |
56
+
57
+ ## Princípios de Design
58
+
59
+ 1. **Neutralidade de domínio** — o core nunca sabe o que é IBGE, DATASUS ou INMET.
60
+ 2. **Leveza** — dependências mínimas no núcleo; integrações pesadas são extras opcionais.
61
+ 3. **Estabilidade** — alta cobertura de testes em todos os componentes de infraestrutura.
62
+ 4. **DX** — APIs tipadas e tratamento de erros consistente em toda a organização.
63
+
64
+ ## Desenvolvimento
65
+
66
+ ```bash
67
+ git clone https://github.com/Quantilica/quantilica-core.git
68
+ cd quantilica-core
69
+ uv sync --dev
70
+ uv run pytest
71
+ ```
72
+
73
+ ## Licença
74
+
75
+ MIT — veja [LICENSE](LICENSE).
@@ -0,0 +1,67 @@
1
+ [project]
2
+ name = "quantilica-core"
3
+ version = "0.3.1"
4
+ description = "Common foundation utilities for Quantilica data projects"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Komesu, D.K.", email = "daniel@dkko.me" },
8
+ ]
9
+ license = "MIT"
10
+ license-files = ["LICENSE"]
11
+ requires-python = ">=3.12"
12
+ keywords = [
13
+ "data",
14
+ "open-data",
15
+ "brazil",
16
+ "quantilica",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 3 - Alpha",
20
+ "Intended Audience :: Developers",
21
+ "Intended Audience :: Science/Research",
22
+ "Operating System :: OS Independent",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Software Development :: Libraries :: Python Modules",
27
+ "Typing :: Typed",
28
+ ]
29
+ dependencies = [
30
+ "httpx>=0.28.1",
31
+ "tqdm>=4.67.1",
32
+ ]
33
+
34
+ [project.optional-dependencies]
35
+ cli = [
36
+ "rich>=13.0.0",
37
+ "typer>=0.15.0",
38
+ ]
39
+
40
+ [project.urls]
41
+ Homepage = "https://github.com/Quantilica/quantilica-core"
42
+ Repository = "https://github.com/Quantilica/quantilica-core"
43
+ Issues = "https://github.com/Quantilica/quantilica-core/issues"
44
+
45
+ [dependency-groups]
46
+ dev = [
47
+ "pytest>=8.0",
48
+ "ruff>=0.9.0",
49
+ ]
50
+
51
+ [tool.pytest.ini_options]
52
+ testpaths = ["tests"]
53
+ addopts = ["-p", "no:cacheprovider"]
54
+
55
+ [tool.ruff]
56
+ line-length = 88
57
+
58
+ [tool.ruff.lint]
59
+ select = ["E", "F", "I", "UP", "B"]
60
+
61
+ [tool.hatch.build.targets.wheel]
62
+ sources = ["src"]
63
+ packages = ["src/quantilica"]
64
+
65
+ [build-system]
66
+ requires = ["hatchling>=1.27"]
67
+ build-backend = "hatchling.build"
@@ -0,0 +1,10 @@
1
+ """Common foundation utilities for Quantilica data projects."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("quantilica-core")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0"
9
+
10
+ __all__ = ["__version__"]
@@ -0,0 +1,124 @@
1
+ """File-based cache helpers for deterministic data downloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from collections.abc import Mapping
8
+ from dataclasses import asdict, dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from urllib.parse import urlencode
12
+
13
+ from .dates import isoformat_utc, parse_iso_datetime, utc_now
14
+ from .exceptions import StorageError
15
+ from .files import ensure_dir, sha256_bytes, write_bytes_atomic, write_text_atomic
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class CacheEntry:
20
+ """Metadata for one cached object."""
21
+
22
+ key: str
23
+ path: str
24
+ created_at: str
25
+ size_bytes: int
26
+ sha256: str
27
+ metadata: dict[str, Any]
28
+
29
+
30
+ class FileCache:
31
+ """Simple content cache stored on the local filesystem."""
32
+
33
+ def __init__(self, root: str | os.PathLike[str]) -> None:
34
+ self.root = ensure_dir(root)
35
+
36
+ def key_for_url(
37
+ self,
38
+ url: str,
39
+ *,
40
+ params: Mapping[str, Any] | None = None,
41
+ headers: Mapping[str, str] | None = None,
42
+ ) -> str:
43
+ """Return a deterministic key for a URL request."""
44
+ parts = [url]
45
+ if params:
46
+ parts.append(urlencode(sorted(params.items()), doseq=True))
47
+ if headers:
48
+ normalized_headers = {
49
+ key.lower(): value for key, value in sorted(headers.items())
50
+ }
51
+ parts.append(json.dumps(normalized_headers, sort_keys=True))
52
+ return sha256_bytes("\n".join(parts).encode("utf-8"))
53
+
54
+ def content_path(self, key: str) -> Path:
55
+ """Return the content path for a cache key."""
56
+ return self.root / key[:2] / key[2:]
57
+
58
+ def metadata_path(self, key: str) -> Path:
59
+ """Return the metadata path for a cache key."""
60
+ return self.root / key[:2] / f"{key[2:]}.json"
61
+
62
+ def exists(self, key: str) -> bool:
63
+ """Return True when content and metadata exist for a key."""
64
+ return self.content_path(key).exists() and self.metadata_path(key).exists()
65
+
66
+ def read_bytes(self, key: str) -> bytes:
67
+ """Read cached bytes."""
68
+ path = self.content_path(key)
69
+ try:
70
+ return path.read_bytes()
71
+ except OSError as exc:
72
+ raise StorageError(f"Could not read cache entry: {key}") from exc
73
+
74
+ def read_metadata(self, key: str) -> CacheEntry:
75
+ """Read cache metadata."""
76
+ path = self.metadata_path(key)
77
+ try:
78
+ payload = json.loads(path.read_text(encoding="utf-8"))
79
+ except (OSError, json.JSONDecodeError) as exc:
80
+ raise StorageError(f"Could not read cache metadata: {key}") from exc
81
+ return CacheEntry(**payload)
82
+
83
+ def write_bytes(
84
+ self,
85
+ key: str,
86
+ content: bytes,
87
+ *,
88
+ metadata: Mapping[str, Any] | None = None,
89
+ ) -> CacheEntry:
90
+ """Write content and metadata for a cache key."""
91
+ content_path = self.content_path(key)
92
+ metadata_path = self.metadata_path(key)
93
+ write_bytes_atomic(content_path, content)
94
+
95
+ entry = CacheEntry(
96
+ key=key,
97
+ path=str(content_path.relative_to(self.root)),
98
+ created_at=isoformat_utc(),
99
+ size_bytes=len(content),
100
+ sha256=sha256_bytes(content),
101
+ metadata=dict(metadata or {}),
102
+ )
103
+ write_text_atomic(
104
+ metadata_path,
105
+ json.dumps(asdict(entry), ensure_ascii=False, indent=2, sort_keys=True),
106
+ )
107
+ return entry
108
+
109
+ def is_fresh(self, key: str, *, ttl_seconds: int | None = None) -> bool:
110
+ """Return True if a cache entry exists and is within TTL."""
111
+ if not self.exists(key):
112
+ return False
113
+ if ttl_seconds is None:
114
+ return True
115
+ entry = self.read_metadata(key)
116
+ created_at = parse_iso_datetime(entry.created_at)
117
+ age = (utc_now() - created_at).total_seconds()
118
+ return age <= ttl_seconds
119
+
120
+ def get_bytes(self, key: str, *, ttl_seconds: int | None = None) -> bytes | None:
121
+ """Return cached bytes when present and fresh, otherwise None."""
122
+ if not self.is_fresh(key, ttl_seconds=ttl_seconds):
123
+ return None
124
+ return self.read_bytes(key)
@@ -0,0 +1,109 @@
1
+ """Shared Rich-based helpers for quantilica fetcher CLI plugins.
2
+
3
+ These helpers are host-only: ``plugin.py`` modules run inside the
4
+ ``quantilica-cli`` host, which pulls in ``rich`` via the ``cli`` extra
5
+ (``quantilica-core[cli]``). Standalone argparse CLIs should keep using
6
+ :func:`quantilica.core.logging.configure_cli_logging` instead.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+
13
+ try:
14
+ from rich.console import Console
15
+ from rich.logging import RichHandler
16
+ from rich.progress import (
17
+ BarColumn,
18
+ DownloadColumn,
19
+ MofNCompleteColumn,
20
+ Progress,
21
+ SpinnerColumn,
22
+ TextColumn,
23
+ TimeElapsedColumn,
24
+ TimeRemainingColumn,
25
+ TransferSpeedColumn,
26
+ )
27
+ except ModuleNotFoundError as exc: # pragma: no cover
28
+ raise ModuleNotFoundError(
29
+ "quantilica.core.cli requires the 'cli' extra; install quantilica-core[cli]"
30
+ ) from exc
31
+
32
+
33
+ _console: Console | None = None
34
+
35
+
36
+ def get_console() -> Console:
37
+ """Return a process-wide shared Rich console."""
38
+ global _console
39
+ if _console is None:
40
+ _console = Console()
41
+ return _console
42
+
43
+
44
+ def setup_rich_logging(
45
+ verbose: bool,
46
+ *,
47
+ console: Console | None = None,
48
+ ) -> None:
49
+ """Configure logging via ``RichHandler`` without breaking progress bars.
50
+
51
+ ``verbose=False`` → WARNING only; ``verbose=True`` → DEBUG.
52
+ """
53
+ level = logging.DEBUG if verbose else logging.WARNING
54
+ logging.basicConfig(
55
+ level=level,
56
+ format="%(message)s",
57
+ datefmt="[%X]",
58
+ handlers=[RichHandler(console=console or get_console(), show_path=False)],
59
+ force=True,
60
+ )
61
+
62
+
63
+ def make_batch_progress(console: Console | None = None) -> Progress:
64
+ """Build a Progress for overall/batch tracking (file counts)."""
65
+ return Progress(
66
+ SpinnerColumn(),
67
+ TextColumn("[progress.description]{task.description}"),
68
+ BarColumn(),
69
+ MofNCompleteColumn(),
70
+ TimeElapsedColumn(),
71
+ TimeRemainingColumn(),
72
+ console=console or get_console(),
73
+ )
74
+
75
+
76
+ def make_download_progress(console: Console | None = None) -> Progress:
77
+ """Build a Progress for individual file downloads (bytes/speed)."""
78
+ return Progress(
79
+ SpinnerColumn(),
80
+ TextColumn("[dim]{task.description}[/dim]"),
81
+ BarColumn(),
82
+ DownloadColumn(),
83
+ TransferSpeedColumn(),
84
+ TimeRemainingColumn(),
85
+ console=console or get_console(),
86
+ )
87
+
88
+
89
+ def expand_years_cli(
90
+ years: list[str] | None,
91
+ default_range: str | None = None,
92
+ console: Console | None = None,
93
+ ) -> list[int]:
94
+ """Expand CLI year/range arguments (e.g. ``["2020:2022", "2024"]``).
95
+
96
+ If ``years`` is empty and ``default_range`` is provided, it expands the
97
+ default range. Prints a warning to the console/stderr for any invalid specs.
98
+ """
99
+ from quantilica.core.dates import expand_year_range
100
+
101
+ con = console or get_console()
102
+ specs = years if years else ([default_range] if default_range else [])
103
+ result: list[int] = []
104
+ for arg in specs:
105
+ try:
106
+ result.extend(expand_year_range(arg))
107
+ except ValueError:
108
+ con.print(f"[yellow]Aviso:[/yellow] ano/intervalo inválido '{arg}'")
109
+ return result