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.
- quantilica_core-0.3.1/.github/workflows/publish.yml +66 -0
- quantilica_core-0.3.1/.github/workflows/test.yml +38 -0
- quantilica_core-0.3.1/.gitignore +38 -0
- quantilica_core-0.3.1/CHANGELOG.md +17 -0
- quantilica_core-0.3.1/LICENSE +21 -0
- quantilica_core-0.3.1/PKG-INFO +103 -0
- quantilica_core-0.3.1/README.md +75 -0
- quantilica_core-0.3.1/pyproject.toml +67 -0
- quantilica_core-0.3.1/src/quantilica/core/__init__.py +10 -0
- quantilica_core-0.3.1/src/quantilica/core/cache.py +124 -0
- quantilica_core-0.3.1/src/quantilica/core/cli.py +109 -0
- quantilica_core-0.3.1/src/quantilica/core/config.py +97 -0
- quantilica_core-0.3.1/src/quantilica/core/dates.py +61 -0
- quantilica_core-0.3.1/src/quantilica/core/exceptions.py +29 -0
- quantilica_core-0.3.1/src/quantilica/core/fetcher.py +157 -0
- quantilica_core-0.3.1/src/quantilica/core/files.py +165 -0
- quantilica_core-0.3.1/src/quantilica/core/ftp.py +287 -0
- quantilica_core-0.3.1/src/quantilica/core/http.py +909 -0
- quantilica_core-0.3.1/src/quantilica/core/logging.py +90 -0
- quantilica_core-0.3.1/src/quantilica/core/manifests.py +335 -0
- quantilica_core-0.3.1/src/quantilica/core/metadata.py +316 -0
- quantilica_core-0.3.1/src/quantilica/core/platform.py +210 -0
- quantilica_core-0.3.1/src/quantilica/core/progress.py +61 -0
- quantilica_core-0.3.1/src/quantilica/core/py.typed +1 -0
- quantilica_core-0.3.1/src/quantilica/core/retry.py +163 -0
- quantilica_core-0.3.1/src/quantilica/core/storage.py +256 -0
- quantilica_core-0.3.1/tests/test_cache.py +60 -0
- quantilica_core-0.3.1/tests/test_cli.py +47 -0
- quantilica_core-0.3.1/tests/test_config.py +81 -0
- quantilica_core-0.3.1/tests/test_dates.py +71 -0
- quantilica_core-0.3.1/tests/test_fetcher.py +141 -0
- quantilica_core-0.3.1/tests/test_files.py +74 -0
- quantilica_core-0.3.1/tests/test_http.py +334 -0
- quantilica_core-0.3.1/tests/test_logging.py +93 -0
- quantilica_core-0.3.1/tests/test_manifests.py +176 -0
- quantilica_core-0.3.1/tests/test_metadata.py +197 -0
- quantilica_core-0.3.1/tests/test_platform.py +154 -0
- quantilica_core-0.3.1/tests/test_retry.py +64 -0
- 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
|
+
 
|
|
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
|
+
 
|
|
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
|