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/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)