-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsidra.py
More file actions
330 lines (284 loc) · 12.1 KB
/
Copy pathsidra.py
File metadata and controls
330 lines (284 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
"""Utilities to fetch and store SIDRA tables.
This module provides a `Fetcher` context-managed helper that wraps the
`sidra_fetcher` client to download SIDRA tables as CSV-backed pandas
DataFrames and write them to the project's data directory. It also
contains a helper `unnest_classificacoes` which expands nested
classification/category combinations into flat dictionaries suitable for
request parameters.
Public API
- `Fetcher`: context-managed client for downloading SIDRA tables.
- `unnest_classificacoes`: yields classification/category mappings.
"""
import logging
import threading
from collections.abc import Callable, Generator
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any
import httpx
from sidra_fetcher.agregados import Agregado, Classificacao
from sidra_fetcher.fetcher import SidraClient
from sidra_fetcher.sidra import Formato, Parametro, Precisao
from .config import Config
from .storage import Storage
logger = logging.getLogger(__name__)
_MAX_RETRIES = 5
_RETRY_BASE_DELAY = 5 # seconds; doubles on each attempt (5, 10, 20, 40, 80)
# Transient network conditions that warrant a retry
_TRANSIENT_ERRORS = (
httpx.ReadTimeout,
httpx.ConnectTimeout,
httpx.ConnectError,
httpx.RemoteProtocolError,
httpx.NetworkError,
)
class Fetcher:
"""Helper to download SIDRA tables and save them locally.
This class wraps a `SidraClient` to provide higher-level operations
to download all periods of a given SIDRA table, write each period's
result to disk and return the written file paths.
Usage example::
with Fetcher() as f:
plan = [(None, p, m) for p, m in f.plan_periods(...)]
files = f.download_periods(plan)
Attributes:
sidra_client: An instance of `SidraClient` used to perform HTTP
requests to the SIDRA API.
storage: `Storage` repository where downloaded files are written.
max_workers: Maximum number of concurrent period downloads.
"""
def __init__(
self,
config: Config,
max_workers: int = 4,
storage: Storage | None = None,
):
self.sidra_client = SidraClient(timeout=600)
self.storage = storage if storage is not None else Storage.default(config)
self.max_workers = max_workers
self._cancel = threading.Event()
def plan_periods(
self,
tabela_sidra: str,
territories: dict[str, list[str]],
variables: list[str] | None = None,
classifications: dict[str, list[str]] | None = None,
) -> list[tuple[Parametro, str]]:
"""Build (Parametro, modification) tuples for every period of a table.
Pure planning — no downloads. Use ``download_periods`` to fetch
a flat plan concurrently across many tables.
Args:
tabela_sidra: SIDRA table code (numeric string accepted).
territories: Mapping of territory type codes to lists of
territory identifiers.
variables: Optional list of variable codes. Defaults to ["all"].
classifications: Optional classification → category mapping.
If omitted, defaults to empty list for each declared
classification (read from cached or fetched metadata).
Returns:
List of (Parametro, modification_iso_string) tuples — one per
period of the requested table.
"""
if variables is None:
variables = ["all"]
# Use cached metadata when available — avoids redundant round-trips
# after load_metadata has already fetched and stored the Agregado.
metadata_path = self.storage.get_metadata_filepath(tabela_sidra)
if metadata_path.exists():
metadados = self.storage.read_metadata(tabela_sidra)
else:
metadados = self.sidra_client.get_agregado_metadados(int(tabela_sidra))
if classifications is None:
classifications = {str(c.id): [] for c in metadados.classificacoes}
periodos = getattr(
metadados, "periodos", None
) or self.sidra_client.get_agregado_periodos(agregado_id=int(tabela_sidra))
period_params: list[tuple[Parametro, str]] = []
for periodo in periodos:
parameter = Parametro(
agregado=tabela_sidra,
territorios=territories,
variaveis=variables,
periodos=[periodo.id],
classificacoes=classifications,
decimais={"": Precisao.M}, # Precisão: Máxima
formato=Formato.A,
)
period_params.append((parameter, periodo.modificacao.isoformat()))
return period_params
def download_periods(
self,
plan: list[tuple[Any, Parametro, str]],
on_file_done: Callable[[Any], None] | None = None,
) -> list[dict[str, Any]]:
"""Download many periods concurrently from a flat plan.
Submits every (Parametro, modification) entry of ``plan`` to a
single ``ThreadPoolExecutor`` capped at ``self.max_workers``,
regardless of which source table each entry came from.
Args:
plan: Tuples of (key, parameter, modification). ``key`` is
opaque metadata returned alongside each result so callers
can correlate downloads back to their originating request.
on_file_done: Optional callback fired once per completed
download (success or failure), useful for progress bars.
Returns:
List of dicts with keys "key", "filepath", "modificacao", in
completion order. Raises the first download error after all
futures complete.
"""
results: list[dict[str, Any]] = []
errors: list[Exception] = []
executor = ThreadPoolExecutor(max_workers=self.max_workers)
try:
future_to_meta = {
executor.submit(self._download_period, parameter, modification): (
key,
modification,
)
for key, parameter, modification in plan
}
for future in as_completed(future_to_meta):
key, modification = future_to_meta[future]
try:
results.append(
{
"key": key,
"filepath": future.result(),
"modificacao": modification,
}
)
except Exception as e:
logger.error("Period download failed: %s", e)
errors.append(e)
if on_file_done is not None:
on_file_done(key)
except KeyboardInterrupt:
self._cancel.set()
executor.shutdown(wait=True, cancel_futures=True)
raise
else:
executor.shutdown(wait=True)
if errors:
raise errors[0]
return results
def fetch_metadata(self, tabela_sidra: str) -> Agregado:
"""Fetch full metadata for a SIDRA table including localidades and periodos."""
agregado = self.sidra_client.get_agregado_metadados(int(tabela_sidra))
all_niveis = (
agregado.nivel_territorial.administrativo
+ agregado.nivel_territorial.ibge
+ agregado.nivel_territorial.especial
)
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
loc_futures = [
executor.submit(
self.sidra_client.get_agregado_localidades,
agregado_id=int(tabela_sidra),
localidades_nivel=nivel,
)
for nivel in all_niveis
]
localidades = []
for f in loc_futures:
localidades.extend(f.result())
agregado.localidades = localidades
agregado.periodos = self.sidra_client.get_agregado_periodos(int(tabela_sidra))
return agregado
def _download_period(
self,
parameter: Parametro,
modification: str,
) -> Path:
"""Download a single period and save it; return the destination path."""
if self._cancel.is_set():
raise InterruptedError("cancelled")
if self.storage.exists(parameter, modification):
filepath = self.storage.get_data_filepath(parameter, modification)
logger.debug("File already exists (cache hit): %s", filepath)
return filepath
logger.info(
"Downloading %s",
self.storage.get_data_filepath(parameter, modification).name,
)
data = self.get_table(parameter)
return self.storage.write_data(
data=data, parameter=parameter, modification=modification
)
def get_table(self, parameter: Parametro) -> dict:
"""Request a SIDRA table and return it as a dictionary.
Retries up to `_MAX_RETRIES` times on transient network errors
using exponential backoff (5 s, 10 s, 20 s, …). Raises the
underlying exception once all attempts are exhausted.
Args:
parameter: A `Parametro` instance with the desired request
configuration.
Returns:
A `dict` constructed from the JSON response.
"""
url = parameter.url()
for attempt in range(_MAX_RETRIES):
if self._cancel.is_set():
raise InterruptedError("cancelled")
try:
return self.sidra_client.get(url)
except _TRANSIENT_ERRORS as e:
if attempt >= _MAX_RETRIES - 1:
raise
delay = _RETRY_BASE_DELAY * (2**attempt)
logger.error("%s while fetching data: %s", type(e).__name__, e)
logger.info(
"Retrying in %d s (attempt %d/%d)…",
delay,
attempt + 1,
_MAX_RETRIES,
)
if self._cancel.wait(delay):
raise InterruptedError("cancelled") from None
def __enter__(self):
"""Enter the context manager and return this `Fetcher`."""
self.sidra_client.__enter__()
return self
def __exit__(self, exc_type, exc_value, traceback):
"""Close resources held by the fetcher.
Delegates to the `SidraClient` context manager to ensure any
network resources are cleaned up. Arguments are forwarded from
the context manager protocol.
"""
self.sidra_client.__exit__(exc_type, exc_value, traceback)
def unnest_classificacoes(
classificacoes: list[Classificacao],
data: dict[str, list[str]] | None = None,
) -> Generator[dict[str, list[str]], None, None]:
"""Recursively enumerate classification/category combinations.
SIDRA classifications can be nested. This generator produces a flat
sequence of mappings suitable to pass as the ``classificacoes``
parameter when requesting aggregated data: each yielded dict maps a
classification id (string) to a single-element list containing a
category id (string).
The function skips categories with id "0" which usually represent
an undefined or "all" category.
Args:
classificacoes: List of `Classificacao` objects (from
`sidra_fetcher`) to expand.
data: Internal accumulator used by recursion; callers should
normally omit this argument.
Yields:
Dictionaries mapping classification id to a singleton list of
category ids, representing one combination of categories across
the provided classifications.
"""
if data is None:
data = {}
if not classificacoes:
return
classificacao = classificacoes[0]
classificacao_id = str(classificacao.id)
for categoria in classificacao.categorias:
categoria_id = str(categoria.id)
if categoria_id == "0":
continue
new_data = {**data, classificacao_id: [categoria_id]}
if len(classificacoes) == 1:
yield new_data
else:
yield from unnest_classificacoes(classificacoes[1:], new_data)