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/storage.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Storage repository for SIDRA data files.
|
|
2
|
+
|
|
3
|
+
This module provides the `Storage` class, which centralizes all
|
|
4
|
+
filesystem operations for SIDRA tables: constructing deterministic file
|
|
5
|
+
paths from a `Parametro`, checking whether a file already exists, and
|
|
6
|
+
reading/writing JSON data files.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import orjson
|
|
13
|
+
from sidra_fetcher.agregados import Agregado
|
|
14
|
+
from sidra_fetcher.reader import load_agregado, save_agregado
|
|
15
|
+
from sidra_fetcher.sidra import Parametro
|
|
16
|
+
|
|
17
|
+
from .config import Config
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Storage:
|
|
23
|
+
def __init__(self, data_dir: Path | str):
|
|
24
|
+
self.data_dir = Path(data_dir)
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def default(cls, config: Config) -> "Storage":
|
|
28
|
+
"""Create a Storage rooted at the data directory from config."""
|
|
29
|
+
data_dir = config.data_dir
|
|
30
|
+
data_dir.mkdir(exist_ok=True, parents=True)
|
|
31
|
+
return cls(data_dir)
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def build_data_filename(parameter: Parametro, modification: str) -> str:
|
|
35
|
+
"""Build a deterministic filename for a SIDRA `Parametro`.
|
|
36
|
+
|
|
37
|
+
The filename encodes the table id, periods, format, territorial
|
|
38
|
+
levels, variables and classifications so that each unique request
|
|
39
|
+
maps to a unique file name. The returned filename ends with
|
|
40
|
+
``@{modification}.json`` where ``modification`` is typically the
|
|
41
|
+
period modification timestamp used by the API.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
parameter: A `Parametro` instance containing request
|
|
45
|
+
configuration (table, periods, territorios, variaveis,
|
|
46
|
+
classificacoes, formato).
|
|
47
|
+
modification: A string representing the modification
|
|
48
|
+
timestamp to append to the filename.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
A string suitable for use as a JSON filename.
|
|
52
|
+
"""
|
|
53
|
+
sidra_table = parameter.agregado
|
|
54
|
+
periods = ",".join(parameter.periodos)
|
|
55
|
+
formato = parameter.formato.value
|
|
56
|
+
name = f"t-{sidra_table}_p-{periods}_f-{formato}"
|
|
57
|
+
for territorial_level in parameter.territorios:
|
|
58
|
+
territorial_codes = ",".join(
|
|
59
|
+
str(code) for code in parameter.territorios[territorial_level]
|
|
60
|
+
)
|
|
61
|
+
if territorial_codes == "":
|
|
62
|
+
territorial_codes = "all"
|
|
63
|
+
name += f"_n{territorial_level}-{territorial_codes}"
|
|
64
|
+
if parameter.variaveis:
|
|
65
|
+
variables = ",".join(str(var) for var in parameter.variaveis)
|
|
66
|
+
name += f"_v-{variables}"
|
|
67
|
+
for classification, categories in parameter.classificacoes.items():
|
|
68
|
+
str_categories = ",".join(categories)
|
|
69
|
+
name += f"_c{classification}-{str_categories}"
|
|
70
|
+
name += f"@{modification}"
|
|
71
|
+
name += ".json"
|
|
72
|
+
return name
|
|
73
|
+
|
|
74
|
+
def get_metadata_filepath(self, agregado: int | str) -> Path:
|
|
75
|
+
"""Return the full path for a table's metadata JSON file."""
|
|
76
|
+
return self.data_dir / f"t-{agregado}" / "metadados.json"
|
|
77
|
+
|
|
78
|
+
def get_data_filepath(
|
|
79
|
+
self,
|
|
80
|
+
parameter: Parametro,
|
|
81
|
+
modification: str,
|
|
82
|
+
) -> Path:
|
|
83
|
+
"""Return the full path for the given parameter and modification."""
|
|
84
|
+
filename = self.build_data_filename(parameter, modification)
|
|
85
|
+
return self.data_dir / f"t-{parameter.agregado}" / filename
|
|
86
|
+
|
|
87
|
+
def exists(self, parameter: Parametro, modification: str) -> bool:
|
|
88
|
+
"""Return True if the file for the given parameter already exists."""
|
|
89
|
+
return self.get_data_filepath(parameter, modification).exists()
|
|
90
|
+
|
|
91
|
+
def write_data(
|
|
92
|
+
self,
|
|
93
|
+
data: dict,
|
|
94
|
+
parameter: Parametro,
|
|
95
|
+
modification: str,
|
|
96
|
+
) -> Path:
|
|
97
|
+
"""Write *data* to disk as JSON and return the destination path."""
|
|
98
|
+
filepath = self.get_data_filepath(parameter, modification)
|
|
99
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
logger.info("Writing file %s", filepath)
|
|
101
|
+
with filepath.open("w", encoding="utf-8") as f:
|
|
102
|
+
f.write(orjson.dumps(data, option=orjson.OPT_INDENT_2).decode())
|
|
103
|
+
return filepath
|
|
104
|
+
|
|
105
|
+
def read_data(self, filepath: Path) -> list[dict]:
|
|
106
|
+
"""Read a JSON file previously written by `write`.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
filepath: Path to the JSON file to read.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
A list of dicts containing the table data.
|
|
113
|
+
"""
|
|
114
|
+
logger.info("Reading file %s", filepath)
|
|
115
|
+
with filepath.open("rb") as f:
|
|
116
|
+
data = orjson.loads(f.read())
|
|
117
|
+
|
|
118
|
+
if len(data) > 1:
|
|
119
|
+
rows = data[1:]
|
|
120
|
+
for row in rows:
|
|
121
|
+
for k, v in row.items():
|
|
122
|
+
if v in ("...", "-"):
|
|
123
|
+
row[k] = None
|
|
124
|
+
return rows
|
|
125
|
+
return []
|
|
126
|
+
|
|
127
|
+
def write_metadata(self, agregado: Agregado) -> Path:
|
|
128
|
+
"""Write *agregado* metadata to disk as JSON and return the destination path."""
|
|
129
|
+
filepath = self.get_metadata_filepath(agregado.id)
|
|
130
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
logger.info("Writing file %s", filepath)
|
|
132
|
+
save_agregado(agregado, filepath)
|
|
133
|
+
return filepath
|
|
134
|
+
|
|
135
|
+
def read_metadata(self, agregado: int | str) -> Agregado:
|
|
136
|
+
"""Read metadata from disk as JSON and return the destination path."""
|
|
137
|
+
filepath = self.get_metadata_filepath(agregado)
|
|
138
|
+
agregado = load_agregado(filepath)
|
|
139
|
+
return agregado
|
|
140
|
+
|
|
141
|
+
def read_data_dir(self, dirpath: Path) -> list[dict]:
|
|
142
|
+
# Group files by base name (before the @modification suffix) and keep
|
|
143
|
+
# only the file with the latest modification per parameter combination.
|
|
144
|
+
latest: dict[str, tuple[Path, str]] = {}
|
|
145
|
+
for f in dirpath.glob("*.json"):
|
|
146
|
+
stem = f.stem
|
|
147
|
+
if "@" in stem:
|
|
148
|
+
base, mod = stem.rsplit("@", 1)
|
|
149
|
+
else:
|
|
150
|
+
base, mod = stem, ""
|
|
151
|
+
if base not in latest or mod > latest[base][1]:
|
|
152
|
+
latest[base] = (f, mod)
|
|
153
|
+
data = []
|
|
154
|
+
for f, _ in latest.values():
|
|
155
|
+
data.extend(self.read_data(f))
|
|
156
|
+
return data
|
sidra_sql/toml_runner.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Pipeline runner driven by a TOML configuration file.
|
|
2
|
+
|
|
3
|
+
``TomlScript`` reads a TOML file that declares which SIDRA tables to fetch
|
|
4
|
+
and drives the full ETL pipeline: metadata → download → database load.
|
|
5
|
+
|
|
6
|
+
TOML schema
|
|
7
|
+
-----------
|
|
8
|
+
Each ``[[tabelas]]`` entry maps directly to a `sidra.Fetcher.download_table`
|
|
9
|
+
call. Two optional boolean flags extend the static format:
|
|
10
|
+
|
|
11
|
+
``unnest_classifications = true``
|
|
12
|
+
Fetch the table's metadata at runtime and expand every classification /
|
|
13
|
+
category combination with `sidra.unnest_classificacoes`. The
|
|
14
|
+
``classifications`` key, if present, is ignored.
|
|
15
|
+
|
|
16
|
+
``unnest_classifications = ["id1", "id2"]``
|
|
17
|
+
Same expansion, but only for the listed classification IDs (integers also
|
|
18
|
+
accepted). The ``classifications`` key, if present, is merged as static
|
|
19
|
+
defaults — unnested entries take precedence on key conflicts.
|
|
20
|
+
|
|
21
|
+
``split_variables = true``
|
|
22
|
+
Issue one request per variable listed in ``variables`` instead of a
|
|
23
|
+
single request with all variables.
|
|
24
|
+
|
|
25
|
+
Example TOML
|
|
26
|
+
~~~~~~~~~~~~
|
|
27
|
+
::
|
|
28
|
+
|
|
29
|
+
[[tabelas]]
|
|
30
|
+
tabela_sidra = "5938"
|
|
31
|
+
variables = ["37", "498"]
|
|
32
|
+
territories = {6 = ["all"]}
|
|
33
|
+
|
|
34
|
+
[[tabelas]]
|
|
35
|
+
tabela_sidra = "1613"
|
|
36
|
+
variables = ["allxp"]
|
|
37
|
+
territories = {6 = []}
|
|
38
|
+
unnest_classifications = true
|
|
39
|
+
|
|
40
|
+
[[tabelas]]
|
|
41
|
+
tabela_sidra = "5938"
|
|
42
|
+
variables = ["allxp"]
|
|
43
|
+
territories = {6 = []}
|
|
44
|
+
classifications = {81 = ["allxt"]}
|
|
45
|
+
unnest_classifications = ["87"]
|
|
46
|
+
|
|
47
|
+
[[tabelas]]
|
|
48
|
+
tabela_sidra = "1002"
|
|
49
|
+
variables = ["109", "216", "214", "112"]
|
|
50
|
+
split_variables = true
|
|
51
|
+
territories = {6 = []}
|
|
52
|
+
classifications = {81 = ["allxt"]}
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
import logging
|
|
56
|
+
import tomllib
|
|
57
|
+
from collections.abc import Iterable
|
|
58
|
+
from pathlib import Path
|
|
59
|
+
from typing import Any
|
|
60
|
+
|
|
61
|
+
import sqlalchemy as sa
|
|
62
|
+
from rich.console import Console
|
|
63
|
+
from rich.progress import (
|
|
64
|
+
BarColumn,
|
|
65
|
+
MofNCompleteColumn,
|
|
66
|
+
Progress,
|
|
67
|
+
SpinnerColumn,
|
|
68
|
+
TaskID,
|
|
69
|
+
TextColumn,
|
|
70
|
+
TimeElapsedColumn,
|
|
71
|
+
TimeRemainingColumn,
|
|
72
|
+
)
|
|
73
|
+
from rich.table import Table
|
|
74
|
+
from rich.text import Text
|
|
75
|
+
|
|
76
|
+
from . import database, models, sidra
|
|
77
|
+
from .config import Config
|
|
78
|
+
from .storage import Storage
|
|
79
|
+
|
|
80
|
+
logger = logging.getLogger(__name__)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class _MainOnlyTimeElapsedColumn(TimeElapsedColumn):
|
|
84
|
+
def render(self, task):
|
|
85
|
+
if not task.fields.get("main"):
|
|
86
|
+
return Text("")
|
|
87
|
+
return super().render(task)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class _MainOnlyTimeRemainingColumn(TimeRemainingColumn):
|
|
91
|
+
def render(self, task):
|
|
92
|
+
if not task.fields.get("main"):
|
|
93
|
+
return Text("")
|
|
94
|
+
return super().render(task)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _make_progress(console: Console | None) -> Progress:
|
|
98
|
+
return Progress(
|
|
99
|
+
SpinnerColumn(finished_text="[green]✓[/green]"),
|
|
100
|
+
TextColumn("[progress.description]{task.description}", table_column=None),
|
|
101
|
+
BarColumn(bar_width=28),
|
|
102
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%", style="grey70"),
|
|
103
|
+
_MainOnlyTimeElapsedColumn(),
|
|
104
|
+
_MainOnlyTimeRemainingColumn(),
|
|
105
|
+
console=console,
|
|
106
|
+
transient=False,
|
|
107
|
+
disable=console is None,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _make_download_progress(console: Console | None) -> Progress:
|
|
112
|
+
return Progress(
|
|
113
|
+
SpinnerColumn(finished_text="[green]✓[/green]"),
|
|
114
|
+
TextColumn("[progress.description]{task.description}", table_column=None),
|
|
115
|
+
BarColumn(bar_width=28),
|
|
116
|
+
MofNCompleteColumn(),
|
|
117
|
+
_MainOnlyTimeElapsedColumn(),
|
|
118
|
+
_MainOnlyTimeRemainingColumn(),
|
|
119
|
+
console=console,
|
|
120
|
+
transient=False,
|
|
121
|
+
disable=console is None,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TomlScript:
|
|
126
|
+
"""ETL pipeline runner that loads table definitions from a TOML file.
|
|
127
|
+
|
|
128
|
+
Drives the full pipeline:
|
|
129
|
+
|
|
130
|
+
1. Create ORM tables if they don't exist.
|
|
131
|
+
2. Fetch and save metadata (tabela_sidra, localidade).
|
|
132
|
+
3. Download all data files.
|
|
133
|
+
4. Load data rows into the dados table (also upserts dimensions).
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
def __init__(
|
|
137
|
+
self,
|
|
138
|
+
config: Config,
|
|
139
|
+
toml_path: Path,
|
|
140
|
+
max_workers: int = 4,
|
|
141
|
+
force_metadata: bool = False,
|
|
142
|
+
force_load: bool = False,
|
|
143
|
+
console: Console | None = None,
|
|
144
|
+
):
|
|
145
|
+
self.config = config
|
|
146
|
+
self.toml_path = toml_path
|
|
147
|
+
self.force_metadata = force_metadata
|
|
148
|
+
self.force_load = force_load
|
|
149
|
+
self.console = console
|
|
150
|
+
self.storage = Storage.default(config)
|
|
151
|
+
self.fetcher = sidra.Fetcher(
|
|
152
|
+
config, max_workers=max_workers, storage=self.storage
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def get_tabelas(self) -> Iterable[dict[str, Any]]:
|
|
156
|
+
"""Read the TOML file and return an expanded list of table request dicts."""
|
|
157
|
+
with open(self.toml_path, "rb") as f:
|
|
158
|
+
data = tomllib.load(f)
|
|
159
|
+
|
|
160
|
+
result: list[dict[str, Any]] = []
|
|
161
|
+
for entry in data.get("tabelas", []):
|
|
162
|
+
entry = dict(entry)
|
|
163
|
+
unnest = entry.pop("unnest_classifications", False)
|
|
164
|
+
split_vars = entry.pop("split_variables", False)
|
|
165
|
+
|
|
166
|
+
if unnest is not False:
|
|
167
|
+
metadados = self.fetcher.sidra_client.get_agregado_metadados(
|
|
168
|
+
entry["tabela_sidra"]
|
|
169
|
+
)
|
|
170
|
+
if unnest is True:
|
|
171
|
+
entry.pop("classifications", None)
|
|
172
|
+
to_unnest = metadados.classificacoes
|
|
173
|
+
static_cls: dict[str, list[str]] = {}
|
|
174
|
+
else:
|
|
175
|
+
unnest_ids = {str(u) for u in unnest}
|
|
176
|
+
to_unnest = [
|
|
177
|
+
c for c in metadados.classificacoes if str(c.id) in unnest_ids
|
|
178
|
+
]
|
|
179
|
+
static_cls = entry.pop("classifications", {})
|
|
180
|
+
for c in metadados.classificacoes:
|
|
181
|
+
cls_id = str(c.id)
|
|
182
|
+
if cls_id not in unnest_ids and cls_id not in static_cls:
|
|
183
|
+
static_cls[cls_id] = ["all"]
|
|
184
|
+
for cls_combo in sidra.unnest_classificacoes(to_unnest):
|
|
185
|
+
result.append(
|
|
186
|
+
{
|
|
187
|
+
**entry,
|
|
188
|
+
"classifications": {**static_cls, **cls_combo},
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
elif split_vars:
|
|
192
|
+
variables = entry.pop("variables")
|
|
193
|
+
for var in variables:
|
|
194
|
+
result.append({**entry, "variables": [var]})
|
|
195
|
+
else:
|
|
196
|
+
result.append(entry)
|
|
197
|
+
|
|
198
|
+
return result
|
|
199
|
+
|
|
200
|
+
def download(self, tabelas: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
201
|
+
"""Download all tables and return a list of data-file descriptors.
|
|
202
|
+
|
|
203
|
+
Builds a flat plan across every requested table and submits it
|
|
204
|
+
through a single ``ThreadPoolExecutor`` so downloads parallelize
|
|
205
|
+
across table boundaries, not just across periods of one table.
|
|
206
|
+
"""
|
|
207
|
+
plan: list[tuple[dict[str, Any], Any, str]] = []
|
|
208
|
+
for tabela in tabelas:
|
|
209
|
+
for parameter, modification in self.fetcher.plan_periods(**tabela):
|
|
210
|
+
plan.append((tabela, parameter, modification))
|
|
211
|
+
results = self.fetcher.download_periods(plan)
|
|
212
|
+
return [
|
|
213
|
+
r["key"] | {"filepath": r["filepath"], "modificacao": r["modificacao"]}
|
|
214
|
+
for r in results
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
def load_metadata(self, engine: sa.Engine, tabelas: Iterable[dict[str, Any]]):
|
|
218
|
+
"""Fetch and persist metadata for all unique SIDRA tables."""
|
|
219
|
+
seen: set[str] = set()
|
|
220
|
+
for tabela in tabelas:
|
|
221
|
+
tabela_sidra_id = tabela["tabela_sidra"]
|
|
222
|
+
if tabela_sidra_id in seen:
|
|
223
|
+
continue
|
|
224
|
+
seen.add(tabela_sidra_id)
|
|
225
|
+
|
|
226
|
+
metadata_filepath = self.storage.get_metadata_filepath(tabela_sidra_id)
|
|
227
|
+
if metadata_filepath.exists() and not self.force_metadata:
|
|
228
|
+
logger.info("Reading cached metadata for table %s", tabela_sidra_id)
|
|
229
|
+
agregado = self.storage.read_metadata(tabela_sidra_id)
|
|
230
|
+
else:
|
|
231
|
+
logger.info("Fetching metadata for table %s", tabela_sidra_id)
|
|
232
|
+
agregado = self.fetcher.fetch_metadata(tabela_sidra_id)
|
|
233
|
+
self.storage.write_metadata(agregado)
|
|
234
|
+
|
|
235
|
+
logger.info("Saving metadata to database for table %s", tabela_sidra_id)
|
|
236
|
+
database.save_agregado(engine, agregado)
|
|
237
|
+
|
|
238
|
+
def run(self):
|
|
239
|
+
"""Execute the full fetch-and-load pipeline."""
|
|
240
|
+
engine = database.get_engine(self.config)
|
|
241
|
+
with engine.begin() as conn:
|
|
242
|
+
conn.exec_driver_sql(
|
|
243
|
+
f'CREATE SCHEMA IF NOT EXISTS "{self.config.db_schema}"'
|
|
244
|
+
)
|
|
245
|
+
models.Base.metadata.create_all(engine)
|
|
246
|
+
database.ensure_vintage_schema(engine)
|
|
247
|
+
try:
|
|
248
|
+
self._run(engine)
|
|
249
|
+
except KeyboardInterrupt:
|
|
250
|
+
if self.console is not None:
|
|
251
|
+
self.console.print("\n[yellow]Interrompido.[/yellow]")
|
|
252
|
+
raise SystemExit(1) from None
|
|
253
|
+
|
|
254
|
+
def _run(self, engine: sa.Engine):
|
|
255
|
+
tabelas = list(self.get_tabelas())
|
|
256
|
+
n_meta = len({t["tabela_sidra"] for t in tabelas})
|
|
257
|
+
s_meta = "tabela" if n_meta == 1 else "tabelas"
|
|
258
|
+
|
|
259
|
+
with self.fetcher:
|
|
260
|
+
with _make_progress(self.console) as progress:
|
|
261
|
+
meta_task = progress.add_task(
|
|
262
|
+
f"Metadados ({n_meta} {s_meta})", total=None, main=True
|
|
263
|
+
)
|
|
264
|
+
self.load_metadata(engine, tabelas)
|
|
265
|
+
progress.update(
|
|
266
|
+
meta_task,
|
|
267
|
+
total=1,
|
|
268
|
+
completed=1,
|
|
269
|
+
description=f"Metadados ({n_meta} {s_meta})",
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
plan: list[tuple[dict[str, Any], Any, str]] = []
|
|
273
|
+
for tabela in tabelas:
|
|
274
|
+
for parameter, modification in self.fetcher.plan_periods(**tabela):
|
|
275
|
+
plan.append((tabela, parameter, modification))
|
|
276
|
+
|
|
277
|
+
n_plan = len(plan)
|
|
278
|
+
if self.console is not None:
|
|
279
|
+
info = Table.grid(padding=(0, 2))
|
|
280
|
+
info.add_column(style="bold")
|
|
281
|
+
info.add_column()
|
|
282
|
+
info.add_row("Pipeline", str(self.toml_path))
|
|
283
|
+
info.add_row("Tabelas", f"{n_meta} {s_meta}")
|
|
284
|
+
info.add_row("Arquivos", str(n_plan))
|
|
285
|
+
info.add_row("Threads", str(self.fetcher.max_workers))
|
|
286
|
+
info.add_row(
|
|
287
|
+
"Banco",
|
|
288
|
+
f"{self.config.db_host}:{self.config.db_port}/{self.config.db_name}"
|
|
289
|
+
f" schema={self.config.db_schema}",
|
|
290
|
+
)
|
|
291
|
+
info.add_row("Storage", str(self.config.data_dir))
|
|
292
|
+
self.console.print(info)
|
|
293
|
+
self.console.print()
|
|
294
|
+
|
|
295
|
+
files_per_table: dict[str, int] = {}
|
|
296
|
+
for tabela, _, _ in plan:
|
|
297
|
+
sid = tabela["tabela_sidra"]
|
|
298
|
+
files_per_table[sid] = files_per_table.get(sid, 0) + 1
|
|
299
|
+
|
|
300
|
+
with _make_download_progress(self.console) as progress:
|
|
301
|
+
global_task = progress.add_task("Download", total=n_plan, main=True)
|
|
302
|
+
task_by_table: dict[str, TaskID] = {}
|
|
303
|
+
if len(files_per_table) > 1:
|
|
304
|
+
for sid, count in files_per_table.items():
|
|
305
|
+
task_by_table[sid] = progress.add_task(
|
|
306
|
+
f"Tabela {sid}", total=count
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
def _on_done(key: dict[str, Any]) -> None:
|
|
310
|
+
sub = task_by_table.get(key["tabela_sidra"])
|
|
311
|
+
if sub is not None:
|
|
312
|
+
progress.advance(sub)
|
|
313
|
+
progress.advance(global_task)
|
|
314
|
+
|
|
315
|
+
results = self.fetcher.download_periods(plan, on_file_done=_on_done)
|
|
316
|
+
data_files = [
|
|
317
|
+
r["key"]
|
|
318
|
+
| {
|
|
319
|
+
"filepath": r["filepath"],
|
|
320
|
+
"modificacao": r["modificacao"],
|
|
321
|
+
}
|
|
322
|
+
for r in results
|
|
323
|
+
]
|
|
324
|
+
for sid, task_id in task_by_table.items():
|
|
325
|
+
progress.update(task_id, description=f"Tabela {sid} ✓")
|
|
326
|
+
progress.update(global_task, description="Download concluído ✓")
|
|
327
|
+
|
|
328
|
+
if not self.force_load:
|
|
329
|
+
loaded = database.get_loaded_filenames(
|
|
330
|
+
engine, {f["filepath"].name for f in data_files}
|
|
331
|
+
)
|
|
332
|
+
data_files = [f for f in data_files if f["filepath"].name not in loaded]
|
|
333
|
+
if not data_files:
|
|
334
|
+
if self.console is not None:
|
|
335
|
+
self.console.print(
|
|
336
|
+
"[green]Todos os arquivos já foram carregados.[/green]"
|
|
337
|
+
)
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
with _make_download_progress(self.console) as progress:
|
|
341
|
+
db_files_per_table: dict[str, int] = {}
|
|
342
|
+
for d in data_files:
|
|
343
|
+
sid = str(d["tabela_sidra"])
|
|
344
|
+
db_files_per_table[sid] = db_files_per_table.get(sid, 0) + 1
|
|
345
|
+
n_db_files = sum(db_files_per_table.values())
|
|
346
|
+
db_global_task = progress.add_task(
|
|
347
|
+
"Carregando no banco de dados", total=n_db_files * 2, main=True
|
|
348
|
+
)
|
|
349
|
+
db_task_by_table: dict[str, TaskID] = {}
|
|
350
|
+
if len(db_files_per_table) > 1:
|
|
351
|
+
for sid, count in db_files_per_table.items():
|
|
352
|
+
db_task_by_table[sid] = progress.add_task(
|
|
353
|
+
f"Tabela {sid}", total=count * 2
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
def _on_db_file_done(sid: str) -> None:
|
|
357
|
+
sub = db_task_by_table.get(sid)
|
|
358
|
+
if sub is not None:
|
|
359
|
+
progress.advance(sub)
|
|
360
|
+
progress.advance(db_global_task)
|
|
361
|
+
|
|
362
|
+
def _on_db_table_done(sid: str) -> None:
|
|
363
|
+
sub = db_task_by_table.get(sid)
|
|
364
|
+
if sub is not None:
|
|
365
|
+
progress.update(sub, description=f"Tabela {sid} ✓")
|
|
366
|
+
|
|
367
|
+
database.load_dados(
|
|
368
|
+
engine,
|
|
369
|
+
self.storage,
|
|
370
|
+
data_files,
|
|
371
|
+
on_file_done=_on_db_file_done,
|
|
372
|
+
on_table_done=_on_db_table_done,
|
|
373
|
+
)
|
|
374
|
+
progress.update(db_global_task, description="Carregamento concluído ✓")
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Pipeline runner for SQL transformations defined by ``transform.toml``.
|
|
2
|
+
|
|
3
|
+
A pipeline directory contains one ``transform.toml`` declaring one or more
|
|
4
|
+
output tables/views. Each ``[[table]]`` entry references its own ``.sql``
|
|
5
|
+
file in the same directory.
|
|
6
|
+
|
|
7
|
+
TOML schema
|
|
8
|
+
-----------
|
|
9
|
+
::
|
|
10
|
+
|
|
11
|
+
[[table]]
|
|
12
|
+
name = "ipca"
|
|
13
|
+
schema = "analytics"
|
|
14
|
+
strategy = "replace" # "replace" or "view"
|
|
15
|
+
sql = "ipca.sql"
|
|
16
|
+
description = "IPCA - série detalhada"
|
|
17
|
+
|
|
18
|
+
[[table]]
|
|
19
|
+
name = "ipca_resumo"
|
|
20
|
+
schema = "analytics"
|
|
21
|
+
strategy = "view"
|
|
22
|
+
sql = "ipca_resumo.sql"
|
|
23
|
+
|
|
24
|
+
Required fields per entry: ``name``, ``schema``, ``strategy``, ``sql``.
|
|
25
|
+
Optional: ``description``, ``primary_key``, ``indexes``.
|
|
26
|
+
|
|
27
|
+
Strategies
|
|
28
|
+
~~~~~~~~~~
|
|
29
|
+
``replace``
|
|
30
|
+
``DROP TABLE IF EXISTS`` followed by ``CREATE TABLE ... AS``.
|
|
31
|
+
Full refresh — best for batch imports into Power BI, Excel, etc.
|
|
32
|
+
|
|
33
|
+
``view``
|
|
34
|
+
``CREATE OR REPLACE VIEW``. Zero storage cost, always up-to-date,
|
|
35
|
+
best for live database connections.
|
|
36
|
+
|
|
37
|
+
Each entry runs in its own transaction; if one fails, previously
|
|
38
|
+
materialized outputs from the same pipeline persist.
|
|
39
|
+
|
|
40
|
+
The SQL queries use unqualified table names (``dados``, ``dimensao``,
|
|
41
|
+
``localidade``, ``tabela_sidra``). They resolve via the database
|
|
42
|
+
``search_path`` set by ``get_engine`` from ``config.ini``.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
import logging
|
|
46
|
+
import tomllib
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
|
|
49
|
+
from rich.console import Console
|
|
50
|
+
from rich.progress import (
|
|
51
|
+
Progress,
|
|
52
|
+
SpinnerColumn,
|
|
53
|
+
TextColumn,
|
|
54
|
+
TimeElapsedColumn,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
from . import database
|
|
58
|
+
from .config import Config
|
|
59
|
+
|
|
60
|
+
logger = logging.getLogger(__name__)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TransformRunner:
|
|
64
|
+
"""Run SQL transformations declared in a ``transform.toml`` file."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, config: Config, toml_path: Path, console: Console | None = None):
|
|
67
|
+
self.config = config
|
|
68
|
+
self.toml_path = toml_path
|
|
69
|
+
self.console = console
|
|
70
|
+
|
|
71
|
+
def run(self):
|
|
72
|
+
with open(self.toml_path, "rb") as f:
|
|
73
|
+
data = tomllib.load(f)
|
|
74
|
+
|
|
75
|
+
tables = data.get("table")
|
|
76
|
+
if not isinstance(tables, list) or not tables:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"{self.toml_path}: esperado um ou mais [[table]] (array). "
|
|
79
|
+
"O schema [table] singular foi removido — migre para [[table]] "
|
|
80
|
+
"com campo 'sql' explícito por entrada."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
engine = database.get_engine(self.config)
|
|
84
|
+
|
|
85
|
+
with Progress(
|
|
86
|
+
SpinnerColumn(finished_text="[green]✓[/green]"),
|
|
87
|
+
TextColumn("[progress.description]{task.description}"),
|
|
88
|
+
TimeElapsedColumn(),
|
|
89
|
+
console=self.console,
|
|
90
|
+
transient=False,
|
|
91
|
+
disable=self.console is None,
|
|
92
|
+
) as progress:
|
|
93
|
+
for entry in tables:
|
|
94
|
+
self._materialize(engine, entry, progress)
|
|
95
|
+
|
|
96
|
+
def _materialize(self, engine, entry: dict, progress: Progress) -> None:
|
|
97
|
+
missing = [f for f in ("name", "schema", "strategy", "sql") if f not in entry]
|
|
98
|
+
if missing:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"{self.toml_path}: [[table]] sem campo(s) obrigatório(s): "
|
|
101
|
+
f"{', '.join(missing)}"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
name = entry["name"]
|
|
105
|
+
schema = entry["schema"]
|
|
106
|
+
strategy = entry["strategy"]
|
|
107
|
+
sql_rel = entry["sql"]
|
|
108
|
+
primary_key = entry.get("primary_key")
|
|
109
|
+
indexes = entry.get("indexes", [])
|
|
110
|
+
|
|
111
|
+
sql_path = self.toml_path.parent / sql_rel
|
|
112
|
+
if not sql_path.exists():
|
|
113
|
+
raise FileNotFoundError(
|
|
114
|
+
f"{self.toml_path}: arquivo SQL '{sql_rel}' não encontrado em "
|
|
115
|
+
f"{self.toml_path.parent}"
|
|
116
|
+
)
|
|
117
|
+
query = sql_path.read_text(encoding="utf-8").strip().replace("%", "%%")
|
|
118
|
+
|
|
119
|
+
qualified = f'"{schema}"."{name}"'
|
|
120
|
+
strategy_label = {"replace": "tabela", "view": "view"}.get(strategy, strategy)
|
|
121
|
+
task = progress.add_task(
|
|
122
|
+
f"{qualified} [dim][{strategy_label}][/dim]", total=None
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
with engine.begin() as conn:
|
|
126
|
+
conn.exec_driver_sql(f'CREATE SCHEMA IF NOT EXISTS "{schema}"')
|
|
127
|
+
|
|
128
|
+
if strategy == "view":
|
|
129
|
+
conn.exec_driver_sql(f"CREATE OR REPLACE VIEW {qualified} AS\n{query}")
|
|
130
|
+
elif strategy == "replace":
|
|
131
|
+
conn.exec_driver_sql(f"DROP TABLE IF EXISTS {qualified}")
|
|
132
|
+
conn.exec_driver_sql(f"CREATE TABLE {qualified} AS\n{query}")
|
|
133
|
+
|
|
134
|
+
if primary_key:
|
|
135
|
+
pk_cols = ", ".join(f'"{c}"' for c in primary_key)
|
|
136
|
+
conn.exec_driver_sql(
|
|
137
|
+
f"ALTER TABLE {qualified} ADD PRIMARY KEY ({pk_cols})"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
for idx in indexes:
|
|
141
|
+
idx_name = idx["name"]
|
|
142
|
+
idx_cols = ", ".join(f'"{c}"' for c in idx["columns"])
|
|
143
|
+
unique = "UNIQUE" if idx.get("unique") else ""
|
|
144
|
+
conn.exec_driver_sql(
|
|
145
|
+
f'CREATE {unique} INDEX "{idx_name}" '
|
|
146
|
+
f"ON {qualified} ({idx_cols})"
|
|
147
|
+
)
|
|
148
|
+
else:
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"Unknown strategy {strategy!r} for {qualified} in {self.toml_path}"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
progress.update(task, total=1, completed=1)
|