sidra-sql 1.3.0__py3-none-any.whl
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.
- sidra_sql/__init__.py +15 -0
- sidra_sql/cli.py +631 -0
- sidra_sql/config.py +137 -0
- sidra_sql/database.py +793 -0
- sidra_sql/exporter.py +171 -0
- sidra_sql/models.py +230 -0
- sidra_sql/plugin_manager.py +200 -0
- sidra_sql/runner.py +76 -0
- sidra_sql/scaffold.py +248 -0
- sidra_sql/sidra.py +362 -0
- sidra_sql/storage.py +156 -0
- sidra_sql/toml_runner.py +374 -0
- sidra_sql/transform_runner.py +153 -0
- sidra_sql/utils.py +106 -0
- sidra_sql/validator.py +222 -0
- sidra_sql-1.3.0.dist-info/METADATA +629 -0
- sidra_sql-1.3.0.dist-info/RECORD +20 -0
- sidra_sql-1.3.0.dist-info/WHEEL +4 -0
- sidra_sql-1.3.0.dist-info/entry_points.txt +2 -0
- sidra_sql-1.3.0.dist-info/licenses/LICENSE +21 -0
sidra_sql/scaffold.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import tomllib
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _slugify(name: str) -> str:
|
|
7
|
+
return name.lower().replace("-", "_").replace(" ", "_")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _fetch_toml_template() -> str:
|
|
11
|
+
return (
|
|
12
|
+
"# Busca dados da API SIDRA (IBGE).\n"
|
|
13
|
+
"# Encontre IDs de tabelas em https://sidra.ibge.gov.br\n"
|
|
14
|
+
"#\n"
|
|
15
|
+
'# tabela_sidra — ID da tabela no SIDRA (ex: "839")\n'
|
|
16
|
+
'# variables — ["allxp"] para todas, ou IDs específicos: '
|
|
17
|
+
'["109", "216"]\n'
|
|
18
|
+
"# territories — {6 = []} todos os municípios; {3 = []} todos os estados\n"
|
|
19
|
+
'# classifications — {81 = ["allxt"]} todas as categorias '
|
|
20
|
+
"(descomente se precisar)\n"
|
|
21
|
+
"# split_variables — true para enviar uma requisição por variável\n"
|
|
22
|
+
"\n"
|
|
23
|
+
"[[tabelas]]\n"
|
|
24
|
+
'tabela_sidra = "XXXX" # substitua pelo ID da tabela\n'
|
|
25
|
+
'variables = ["allxp"]\n'
|
|
26
|
+
"territories = {6 = []} # nível 6 = municípios\n"
|
|
27
|
+
'# classifications = {81 = ["allxt"]}\n'
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _transform_toml_template(slug: str) -> str:
|
|
32
|
+
return (
|
|
33
|
+
"# Cada [[table]] declara uma saída do pipeline.\n"
|
|
34
|
+
"# Para múltiplas saídas, adicione mais blocos [[table]] e crie um\n"
|
|
35
|
+
"# arquivo .sql correspondente para cada um.\n"
|
|
36
|
+
"\n"
|
|
37
|
+
"[[table]]\n"
|
|
38
|
+
f'name = "{slug}"\n'
|
|
39
|
+
'schema = "analytics"\n'
|
|
40
|
+
'strategy = "replace" # "replace" ou "view"\n'
|
|
41
|
+
f'sql = "{slug}.sql"\n'
|
|
42
|
+
'description = "Descrição da tabela de saída"\n'
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _transform_sql_template() -> str:
|
|
47
|
+
return (
|
|
48
|
+
"-- Adapte esta query para o seu pipeline.\n"
|
|
49
|
+
"-- Tabelas normalizadas disponíveis:\n"
|
|
50
|
+
"-- IBGE_SIDRA.DADOS — valores brutos\n"
|
|
51
|
+
"-- PERIODO — dimensão temporal (ano, mês, trimestre...)\n"
|
|
52
|
+
"-- DIMENSAO — variáveis e classificações\n"
|
|
53
|
+
"-- LOCALIDADE — unidades territoriais\n"
|
|
54
|
+
"\n"
|
|
55
|
+
"SELECT\n"
|
|
56
|
+
" P.ANO AS ANO,\n"
|
|
57
|
+
" L.D1C AS ID_MUNICIPIO,\n"
|
|
58
|
+
" L.D1N AS NOME_MUNICIPIO,\n"
|
|
59
|
+
" DIM.D2N AS VARIAVEL,\n"
|
|
60
|
+
" DIM.MN AS UNIDADE,\n"
|
|
61
|
+
" CASE WHEN D.V ~ '^-?[0-9]' THEN D.V::NUMERIC END AS VALOR\n"
|
|
62
|
+
"FROM\n"
|
|
63
|
+
" IBGE_SIDRA.DADOS D\n"
|
|
64
|
+
" JOIN PERIODO P ON D.PERIODO_ID = P.ID\n"
|
|
65
|
+
" JOIN DIMENSAO DIM ON D.DIMENSAO_ID = DIM.ID\n"
|
|
66
|
+
" JOIN LOCALIDADE L ON D.LOCALIDADE_ID = L.ID\n"
|
|
67
|
+
"WHERE\n"
|
|
68
|
+
" D.tabela_sidra_ID IN ('XXXX') -- substitua pelo(s) ID(s) "
|
|
69
|
+
"da(s) tabela(s)\n"
|
|
70
|
+
" AND D.ATIVO = TRUE;\n"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class PluginScaffolder:
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
name: str,
|
|
78
|
+
description: str,
|
|
79
|
+
version: str,
|
|
80
|
+
output_dir: Path,
|
|
81
|
+
git_init: bool,
|
|
82
|
+
):
|
|
83
|
+
self.name = name
|
|
84
|
+
self.slug = _slugify(name)
|
|
85
|
+
self.description = description
|
|
86
|
+
self.version = version
|
|
87
|
+
self.plugin_dir = Path(output_dir) / name
|
|
88
|
+
self.git_init = git_init
|
|
89
|
+
|
|
90
|
+
def create(self) -> Path:
|
|
91
|
+
if self.plugin_dir.exists():
|
|
92
|
+
raise FileExistsError(f"Directory '{self.plugin_dir}' already exists.")
|
|
93
|
+
|
|
94
|
+
self.plugin_dir.mkdir(parents=True)
|
|
95
|
+
pipeline_dir = self.plugin_dir / self.slug
|
|
96
|
+
pipeline_dir.mkdir()
|
|
97
|
+
|
|
98
|
+
self._write(self.plugin_dir / "manifest.toml", self._manifest())
|
|
99
|
+
self._write(self.plugin_dir / "README.md", self._readme())
|
|
100
|
+
self._write(pipeline_dir / "fetch.toml", _fetch_toml_template())
|
|
101
|
+
self._write(
|
|
102
|
+
pipeline_dir / "transform.toml",
|
|
103
|
+
_transform_toml_template(self.slug),
|
|
104
|
+
)
|
|
105
|
+
self._write(pipeline_dir / f"{self.slug}.sql", _transform_sql_template())
|
|
106
|
+
|
|
107
|
+
if self.git_init:
|
|
108
|
+
self._write(self.plugin_dir / ".gitignore", self._gitignore())
|
|
109
|
+
self._run_git_init()
|
|
110
|
+
|
|
111
|
+
return self.plugin_dir
|
|
112
|
+
|
|
113
|
+
def _write(self, path: Path, content: str) -> None:
|
|
114
|
+
path.write_text(content, encoding="utf-8")
|
|
115
|
+
|
|
116
|
+
def _manifest(self) -> str:
|
|
117
|
+
return (
|
|
118
|
+
f'name = "{self.name}"\n'
|
|
119
|
+
f'description = "{self.description}"\n'
|
|
120
|
+
f'version = "{self.version}"\n'
|
|
121
|
+
f"\n"
|
|
122
|
+
f"[[pipeline]]\n"
|
|
123
|
+
f'id = "{self.slug}"\n'
|
|
124
|
+
f'description = "Descrição do pipeline"\n'
|
|
125
|
+
f'path = "{self.slug}"\n'
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def _readme(self) -> str:
|
|
129
|
+
return (
|
|
130
|
+
f"# {self.name}\n"
|
|
131
|
+
"\n"
|
|
132
|
+
f"{self.description or 'Descrição do plugin.'}\n"
|
|
133
|
+
"\n"
|
|
134
|
+
"## Instalação\n"
|
|
135
|
+
"\n"
|
|
136
|
+
"```bash\n"
|
|
137
|
+
"sidra-sql plugin install <git-url>\n"
|
|
138
|
+
"```\n"
|
|
139
|
+
"\n"
|
|
140
|
+
"## Pipelines\n"
|
|
141
|
+
"\n"
|
|
142
|
+
"| ID | Descrição | Path |\n"
|
|
143
|
+
"|---|---|---|\n"
|
|
144
|
+
f"| {self.slug} | Descrição do pipeline | {self.slug}/ |\n"
|
|
145
|
+
"\n"
|
|
146
|
+
"## Desenvolvimento\n"
|
|
147
|
+
"\n"
|
|
148
|
+
"1. Encontre a tabela desejada em https://sidra.ibge.gov.br\n"
|
|
149
|
+
f"2. Edite `{self.slug}/fetch.toml` com o ID da tabela e variáveis\n"
|
|
150
|
+
f"3. Ajuste `{self.slug}/{self.slug}.sql` para a transformação desejada\n"
|
|
151
|
+
f"4. Atualize `{self.slug}/transform.toml` com o nome da tabela de saída\n"
|
|
152
|
+
"5. Adicione mais pipelines em `manifest.toml` conforme necessário\n"
|
|
153
|
+
"\n"
|
|
154
|
+
"### Territórios disponíveis\n"
|
|
155
|
+
"\n"
|
|
156
|
+
"| Código | Nível |\n"
|
|
157
|
+
"|---|---|\n"
|
|
158
|
+
"| 1 | Brasil |\n"
|
|
159
|
+
"| 2 | Grandes Regiões |\n"
|
|
160
|
+
"| 3 | Unidades da Federação |\n"
|
|
161
|
+
"| 6 | Municípios |\n"
|
|
162
|
+
"| 7 | Regiões Metropolitanas |\n"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def _gitignore(self) -> str:
|
|
166
|
+
return "__pycache__/\n*.py[cod]\n.env\n.DS_Store\n"
|
|
167
|
+
|
|
168
|
+
def _run_git_init(self) -> None:
|
|
169
|
+
cwd = str(self.plugin_dir)
|
|
170
|
+
try:
|
|
171
|
+
subprocess.run(["git", "init"], cwd=cwd, check=True, capture_output=True)
|
|
172
|
+
subprocess.run(
|
|
173
|
+
["git", "add", "."], cwd=cwd, check=True, capture_output=True
|
|
174
|
+
)
|
|
175
|
+
subprocess.run(
|
|
176
|
+
["git", "commit", "-m", "chore: initial scaffold"],
|
|
177
|
+
cwd=cwd,
|
|
178
|
+
check=True,
|
|
179
|
+
capture_output=True,
|
|
180
|
+
)
|
|
181
|
+
except FileNotFoundError as e:
|
|
182
|
+
raise RuntimeError(
|
|
183
|
+
"git não encontrado. Instale o Git ou use --no-git-init."
|
|
184
|
+
) from e
|
|
185
|
+
except subprocess.CalledProcessError as e:
|
|
186
|
+
raise RuntimeError(
|
|
187
|
+
f"Falha ao inicializar repositório Git: {e.stderr.decode().strip()}"
|
|
188
|
+
) from e
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class PipelineAdder:
|
|
192
|
+
def __init__(
|
|
193
|
+
self,
|
|
194
|
+
pipeline_id: str,
|
|
195
|
+
description: str,
|
|
196
|
+
path: str,
|
|
197
|
+
plugin_dir: Path,
|
|
198
|
+
):
|
|
199
|
+
self.pipeline_id = pipeline_id
|
|
200
|
+
self.slug = _slugify(pipeline_id)
|
|
201
|
+
self.description = description
|
|
202
|
+
self.path = path or self.slug
|
|
203
|
+
self.plugin_dir = Path(plugin_dir)
|
|
204
|
+
self.manifest_path = self.plugin_dir / "manifest.toml"
|
|
205
|
+
self.pipeline_dir = self.plugin_dir / self.path
|
|
206
|
+
|
|
207
|
+
def add(self) -> Path:
|
|
208
|
+
if not self.manifest_path.exists():
|
|
209
|
+
raise FileNotFoundError(
|
|
210
|
+
f"manifest.toml não encontrado em '{self.plugin_dir}'. "
|
|
211
|
+
"Execute o comando dentro do diretório do plugin ou use --plugin-dir."
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
with open(self.manifest_path, "rb") as f:
|
|
215
|
+
manifest = tomllib.load(f)
|
|
216
|
+
existing_ids = {p["id"] for p in manifest.get("pipeline", [])}
|
|
217
|
+
if self.pipeline_id in existing_ids:
|
|
218
|
+
raise ValueError(
|
|
219
|
+
f"Pipeline '{self.pipeline_id}' já existe no manifest.toml."
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
if self.pipeline_dir.exists():
|
|
223
|
+
raise FileExistsError(f"Diretório '{self.pipeline_dir}' já existe.")
|
|
224
|
+
|
|
225
|
+
self.pipeline_dir.mkdir(parents=True)
|
|
226
|
+
self.pipeline_dir.joinpath("fetch.toml").write_text(
|
|
227
|
+
_fetch_toml_template(), encoding="utf-8"
|
|
228
|
+
)
|
|
229
|
+
self.pipeline_dir.joinpath("transform.toml").write_text(
|
|
230
|
+
_transform_toml_template(self.slug), encoding="utf-8"
|
|
231
|
+
)
|
|
232
|
+
self.pipeline_dir.joinpath(f"{self.slug}.sql").write_text(
|
|
233
|
+
_transform_sql_template(), encoding="utf-8"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
self._append_to_manifest()
|
|
237
|
+
return self.pipeline_dir
|
|
238
|
+
|
|
239
|
+
def _append_to_manifest(self) -> None:
|
|
240
|
+
entry = (
|
|
241
|
+
"\n"
|
|
242
|
+
"[[pipeline]]\n"
|
|
243
|
+
f'id = "{self.pipeline_id}"\n'
|
|
244
|
+
f'description = "{self.description}"\n'
|
|
245
|
+
f'path = "{self.path}"\n'
|
|
246
|
+
)
|
|
247
|
+
with open(self.manifest_path, "a", encoding="utf-8") as f:
|
|
248
|
+
f.write(entry)
|
sidra_sql/sidra.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Utilities to fetch and store SIDRA tables.
|
|
2
|
+
|
|
3
|
+
This module provides a `Fetcher` context-managed helper that wraps the
|
|
4
|
+
`sidra_fetcher` client to download SIDRA tables as CSV-backed pandas
|
|
5
|
+
DataFrames and write them to the project's data directory. It also
|
|
6
|
+
contains a helper `unnest_classificacoes` which expands nested
|
|
7
|
+
classification/category combinations into flat dictionaries suitable for
|
|
8
|
+
request parameters.
|
|
9
|
+
|
|
10
|
+
Public API
|
|
11
|
+
- `Fetcher`: context-managed client for downloading SIDRA tables.
|
|
12
|
+
- `unnest_classificacoes`: yields classification/category mappings.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import threading
|
|
17
|
+
from collections.abc import Callable, Generator
|
|
18
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
from sidra_fetcher.agregados import Agregado, Classificacao
|
|
24
|
+
from sidra_fetcher.fetcher import SidraClient
|
|
25
|
+
from sidra_fetcher.sidra import Formato, Parametro, Precisao
|
|
26
|
+
|
|
27
|
+
from .config import Config
|
|
28
|
+
from .storage import Storage
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
_MAX_RETRIES = 5
|
|
33
|
+
_RETRY_BASE_DELAY = 5 # seconds; doubles on each attempt (5, 10, 20, 40, 80)
|
|
34
|
+
|
|
35
|
+
# Transient network conditions that warrant a retry
|
|
36
|
+
_TRANSIENT_ERRORS = (
|
|
37
|
+
httpx.ReadTimeout,
|
|
38
|
+
httpx.ConnectTimeout,
|
|
39
|
+
httpx.ConnectError,
|
|
40
|
+
httpx.RemoteProtocolError,
|
|
41
|
+
httpx.NetworkError,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Fetcher:
|
|
46
|
+
"""Helper to download SIDRA tables and save them locally.
|
|
47
|
+
|
|
48
|
+
This class wraps a `SidraClient` to provide higher-level operations
|
|
49
|
+
to download all periods of a given SIDRA table, write each period's
|
|
50
|
+
result to disk and return the written file paths.
|
|
51
|
+
|
|
52
|
+
Usage example::
|
|
53
|
+
|
|
54
|
+
with Fetcher() as f:
|
|
55
|
+
files = f.download_table(...)
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
sidra_client: An instance of `SidraClient` used to perform HTTP
|
|
59
|
+
requests to the SIDRA API.
|
|
60
|
+
storage: `Storage` repository where downloaded files are written.
|
|
61
|
+
max_workers: Maximum number of concurrent period downloads.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
config: Config,
|
|
67
|
+
max_workers: int = 4,
|
|
68
|
+
storage: Storage | None = None,
|
|
69
|
+
):
|
|
70
|
+
self.sidra_client = SidraClient(timeout=600)
|
|
71
|
+
self.storage = storage if storage is not None else Storage.default(config)
|
|
72
|
+
self.max_workers = max_workers
|
|
73
|
+
self._cancel = threading.Event()
|
|
74
|
+
|
|
75
|
+
def plan_periods(
|
|
76
|
+
self,
|
|
77
|
+
tabela_sidra: str,
|
|
78
|
+
territories: dict[str, list[str]],
|
|
79
|
+
variables: list[str] | None = None,
|
|
80
|
+
classifications: dict[str, list[str]] | None = None,
|
|
81
|
+
) -> list[tuple[Parametro, str]]:
|
|
82
|
+
"""Build (Parametro, modification) tuples for every period of a table.
|
|
83
|
+
|
|
84
|
+
Pure planning — no downloads. Use ``download_periods`` to fetch
|
|
85
|
+
a flat plan concurrently across many tables.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
tabela_sidra: SIDRA table code (numeric string accepted).
|
|
89
|
+
territories: Mapping of territory type codes to lists of
|
|
90
|
+
territory identifiers.
|
|
91
|
+
variables: Optional list of variable codes. Defaults to ["all"].
|
|
92
|
+
classifications: Optional classification → category mapping.
|
|
93
|
+
If omitted, defaults to empty list for each declared
|
|
94
|
+
classification (read from cached or fetched metadata).
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
List of (Parametro, modification_iso_string) tuples — one per
|
|
98
|
+
period of the requested table.
|
|
99
|
+
"""
|
|
100
|
+
if variables is None:
|
|
101
|
+
variables = ["all"]
|
|
102
|
+
|
|
103
|
+
# Use cached metadata when available — avoids redundant round-trips
|
|
104
|
+
# after load_metadata has already fetched and stored the Agregado.
|
|
105
|
+
metadata_path = self.storage.get_metadata_filepath(tabela_sidra)
|
|
106
|
+
if metadata_path.exists():
|
|
107
|
+
metadados = self.storage.read_metadata(tabela_sidra)
|
|
108
|
+
else:
|
|
109
|
+
metadados = self.sidra_client.get_agregado_metadados(int(tabela_sidra))
|
|
110
|
+
|
|
111
|
+
if classifications is None:
|
|
112
|
+
classifications = {str(c.id): [] for c in metadados.classificacoes}
|
|
113
|
+
|
|
114
|
+
periodos = getattr(
|
|
115
|
+
metadados, "periodos", None
|
|
116
|
+
) or self.sidra_client.get_agregado_periodos(agregado_id=int(tabela_sidra))
|
|
117
|
+
|
|
118
|
+
period_params: list[tuple[Parametro, str]] = []
|
|
119
|
+
for periodo in periodos:
|
|
120
|
+
parameter = Parametro(
|
|
121
|
+
agregado=tabela_sidra,
|
|
122
|
+
territorios=territories,
|
|
123
|
+
variaveis=variables,
|
|
124
|
+
periodos=[periodo.id],
|
|
125
|
+
classificacoes=classifications,
|
|
126
|
+
decimais={"": Precisao.M}, # Precisão: Máxima
|
|
127
|
+
formato=Formato.A,
|
|
128
|
+
)
|
|
129
|
+
period_params.append((parameter, periodo.modificacao.isoformat()))
|
|
130
|
+
return period_params
|
|
131
|
+
|
|
132
|
+
def download_periods(
|
|
133
|
+
self,
|
|
134
|
+
plan: list[tuple[Any, Parametro, str]],
|
|
135
|
+
on_file_done: Callable[[Any], None] | None = None,
|
|
136
|
+
) -> list[dict[str, Any]]:
|
|
137
|
+
"""Download many periods concurrently from a flat plan.
|
|
138
|
+
|
|
139
|
+
Submits every (Parametro, modification) entry of ``plan`` to a
|
|
140
|
+
single ``ThreadPoolExecutor`` capped at ``self.max_workers``,
|
|
141
|
+
regardless of which source table each entry came from.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
plan: Tuples of (key, parameter, modification). ``key`` is
|
|
145
|
+
opaque metadata returned alongside each result so callers
|
|
146
|
+
can correlate downloads back to their originating request.
|
|
147
|
+
on_file_done: Optional callback fired once per completed
|
|
148
|
+
download (success or failure), useful for progress bars.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
List of dicts with keys "key", "filepath", "modificacao", in
|
|
152
|
+
completion order. Raises the first download error after all
|
|
153
|
+
futures complete.
|
|
154
|
+
"""
|
|
155
|
+
results: list[dict[str, Any]] = []
|
|
156
|
+
errors: list[Exception] = []
|
|
157
|
+
executor = ThreadPoolExecutor(max_workers=self.max_workers)
|
|
158
|
+
try:
|
|
159
|
+
future_to_meta = {
|
|
160
|
+
executor.submit(self._download_period, parameter, modification): (
|
|
161
|
+
key,
|
|
162
|
+
modification,
|
|
163
|
+
)
|
|
164
|
+
for key, parameter, modification in plan
|
|
165
|
+
}
|
|
166
|
+
for future in as_completed(future_to_meta):
|
|
167
|
+
key, modification = future_to_meta[future]
|
|
168
|
+
try:
|
|
169
|
+
results.append(
|
|
170
|
+
{
|
|
171
|
+
"key": key,
|
|
172
|
+
"filepath": future.result(),
|
|
173
|
+
"modificacao": modification,
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
except Exception as e:
|
|
177
|
+
logger.error("Period download failed: %s", e)
|
|
178
|
+
errors.append(e)
|
|
179
|
+
if on_file_done is not None:
|
|
180
|
+
on_file_done(key)
|
|
181
|
+
except KeyboardInterrupt:
|
|
182
|
+
self._cancel.set()
|
|
183
|
+
executor.shutdown(wait=True, cancel_futures=True)
|
|
184
|
+
raise
|
|
185
|
+
else:
|
|
186
|
+
executor.shutdown(wait=True)
|
|
187
|
+
if errors:
|
|
188
|
+
raise errors[0]
|
|
189
|
+
return results
|
|
190
|
+
|
|
191
|
+
def download_table(
|
|
192
|
+
self,
|
|
193
|
+
tabela_sidra: str,
|
|
194
|
+
territories: dict[str, list[str]],
|
|
195
|
+
variables: list[str] | None = None,
|
|
196
|
+
classifications: dict[str, list[str]] | None = None,
|
|
197
|
+
on_file_done: Callable[[], None] | None = None,
|
|
198
|
+
) -> list[dict[str, Any]]:
|
|
199
|
+
"""Download all periods of a single SIDRA table and save them to disk.
|
|
200
|
+
|
|
201
|
+
Convenience wrapper around ``plan_periods`` + ``download_periods``
|
|
202
|
+
for callers that only need to fetch one table at a time. To
|
|
203
|
+
parallelize across many tables, build a combined plan via
|
|
204
|
+
``plan_periods`` and submit it through ``download_periods``.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
A list of dicts with keys "filepath" (Path) and "modificacao" (str).
|
|
208
|
+
"""
|
|
209
|
+
plan = [
|
|
210
|
+
(None, parameter, modification)
|
|
211
|
+
for parameter, modification in self.plan_periods(
|
|
212
|
+
tabela_sidra=tabela_sidra,
|
|
213
|
+
territories=territories,
|
|
214
|
+
variables=variables,
|
|
215
|
+
classifications=classifications,
|
|
216
|
+
)
|
|
217
|
+
]
|
|
218
|
+
results = self.download_periods(plan, on_file_done=on_file_done)
|
|
219
|
+
return [
|
|
220
|
+
{"filepath": r["filepath"], "modificacao": r["modificacao"]}
|
|
221
|
+
for r in results
|
|
222
|
+
]
|
|
223
|
+
|
|
224
|
+
def fetch_metadata(self, tabela_sidra: str) -> Agregado:
|
|
225
|
+
"""Fetch full metadata for a SIDRA table including localidades and periodos."""
|
|
226
|
+
agregado = self.sidra_client.get_agregado_metadados(int(tabela_sidra))
|
|
227
|
+
|
|
228
|
+
all_niveis = (
|
|
229
|
+
agregado.nivel_territorial.administrativo
|
|
230
|
+
+ agregado.nivel_territorial.ibge
|
|
231
|
+
+ agregado.nivel_territorial.especial
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
|
235
|
+
loc_futures = [
|
|
236
|
+
executor.submit(
|
|
237
|
+
self.sidra_client.get_agregado_localidades,
|
|
238
|
+
agregado_id=int(tabela_sidra),
|
|
239
|
+
localidades_nivel=nivel,
|
|
240
|
+
)
|
|
241
|
+
for nivel in all_niveis
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
localidades = []
|
|
245
|
+
for f in loc_futures:
|
|
246
|
+
localidades.extend(f.result())
|
|
247
|
+
|
|
248
|
+
agregado.localidades = localidades
|
|
249
|
+
agregado.periodos = self.sidra_client.get_agregado_periodos(int(tabela_sidra))
|
|
250
|
+
return agregado
|
|
251
|
+
|
|
252
|
+
def _download_period(
|
|
253
|
+
self,
|
|
254
|
+
parameter: Parametro,
|
|
255
|
+
modification: str,
|
|
256
|
+
) -> Path:
|
|
257
|
+
"""Download a single period and save it; return the destination path."""
|
|
258
|
+
if self._cancel.is_set():
|
|
259
|
+
raise InterruptedError("cancelled")
|
|
260
|
+
if self.storage.exists(parameter, modification):
|
|
261
|
+
filepath = self.storage.get_data_filepath(parameter, modification)
|
|
262
|
+
logger.debug("File already exists (cache hit): %s", filepath)
|
|
263
|
+
return filepath
|
|
264
|
+
logger.info(
|
|
265
|
+
"Downloading %s",
|
|
266
|
+
self.storage.get_data_filepath(parameter, modification).name,
|
|
267
|
+
)
|
|
268
|
+
data = self.get_table(parameter)
|
|
269
|
+
return self.storage.write_data(
|
|
270
|
+
data=data, parameter=parameter, modification=modification
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def get_table(self, parameter: Parametro) -> dict:
|
|
274
|
+
"""Request a SIDRA table and return it as a dictionary.
|
|
275
|
+
|
|
276
|
+
Retries up to `_MAX_RETRIES` times on transient network errors
|
|
277
|
+
using exponential backoff (5 s, 10 s, 20 s, …). Raises the
|
|
278
|
+
underlying exception once all attempts are exhausted.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
parameter: A `Parametro` instance with the desired request
|
|
282
|
+
configuration.
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
A `dict` constructed from the JSON response.
|
|
286
|
+
"""
|
|
287
|
+
url = parameter.url()
|
|
288
|
+
for attempt in range(_MAX_RETRIES):
|
|
289
|
+
if self._cancel.is_set():
|
|
290
|
+
raise InterruptedError("cancelled")
|
|
291
|
+
try:
|
|
292
|
+
return self.sidra_client.get(url)
|
|
293
|
+
except _TRANSIENT_ERRORS as e:
|
|
294
|
+
if attempt >= _MAX_RETRIES - 1:
|
|
295
|
+
raise
|
|
296
|
+
delay = _RETRY_BASE_DELAY * (2**attempt)
|
|
297
|
+
logger.error("%s while fetching data: %s", type(e).__name__, e)
|
|
298
|
+
logger.info(
|
|
299
|
+
"Retrying in %d s (attempt %d/%d)…",
|
|
300
|
+
delay,
|
|
301
|
+
attempt + 1,
|
|
302
|
+
_MAX_RETRIES,
|
|
303
|
+
)
|
|
304
|
+
if self._cancel.wait(delay):
|
|
305
|
+
raise InterruptedError("cancelled") from None
|
|
306
|
+
|
|
307
|
+
def __enter__(self):
|
|
308
|
+
"""Enter the context manager and return this `Fetcher`."""
|
|
309
|
+
self.sidra_client.__enter__()
|
|
310
|
+
return self
|
|
311
|
+
|
|
312
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
313
|
+
"""Close resources held by the fetcher.
|
|
314
|
+
|
|
315
|
+
Delegates to the `SidraClient` context manager to ensure any
|
|
316
|
+
network resources are cleaned up. Arguments are forwarded from
|
|
317
|
+
the context manager protocol.
|
|
318
|
+
"""
|
|
319
|
+
self.sidra_client.__exit__(exc_type, exc_value, traceback)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def unnest_classificacoes(
|
|
323
|
+
classificacoes: list[Classificacao],
|
|
324
|
+
data: dict[str, list[str]] | None = None,
|
|
325
|
+
) -> Generator[dict[str, list[str]], None, None]:
|
|
326
|
+
"""Recursively enumerate classification/category combinations.
|
|
327
|
+
|
|
328
|
+
SIDRA classifications can be nested. This generator produces a flat
|
|
329
|
+
sequence of mappings suitable to pass as the ``classificacoes``
|
|
330
|
+
parameter when requesting aggregated data: each yielded dict maps a
|
|
331
|
+
classification id (string) to a single-element list containing a
|
|
332
|
+
category id (string).
|
|
333
|
+
|
|
334
|
+
The function skips categories with id "0" which usually represent
|
|
335
|
+
an undefined or "all" category.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
classificacoes: List of `Classificacao` objects (from
|
|
339
|
+
`sidra_fetcher`) to expand.
|
|
340
|
+
data: Internal accumulator used by recursion; callers should
|
|
341
|
+
normally omit this argument.
|
|
342
|
+
|
|
343
|
+
Yields:
|
|
344
|
+
Dictionaries mapping classification id to a singleton list of
|
|
345
|
+
category ids, representing one combination of categories across
|
|
346
|
+
the provided classifications.
|
|
347
|
+
"""
|
|
348
|
+
if data is None:
|
|
349
|
+
data = {}
|
|
350
|
+
if not classificacoes:
|
|
351
|
+
return
|
|
352
|
+
classificacao = classificacoes[0]
|
|
353
|
+
classificacao_id = str(classificacao.id)
|
|
354
|
+
for categoria in classificacao.categorias:
|
|
355
|
+
categoria_id = str(categoria.id)
|
|
356
|
+
if categoria_id == "0":
|
|
357
|
+
continue
|
|
358
|
+
new_data = {**data, classificacao_id: [categoria_id]}
|
|
359
|
+
if len(classificacoes) == 1:
|
|
360
|
+
yield new_data
|
|
361
|
+
else:
|
|
362
|
+
yield from unnest_classificacoes(classificacoes[1:], new_data)
|