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/utils.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import itertools
|
|
2
|
+
from collections.abc import Iterable
|
|
3
|
+
|
|
4
|
+
from sidra_fetcher.agregados import (
|
|
5
|
+
Categoria,
|
|
6
|
+
Classificacao,
|
|
7
|
+
Variavel,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def unnest_dimensoes(
|
|
12
|
+
variaveis: list[Variavel],
|
|
13
|
+
classificacoes: list[Classificacao],
|
|
14
|
+
) -> Iterable[dict]:
|
|
15
|
+
"""Expand variables × classification categories into flat Dimensao rows.
|
|
16
|
+
|
|
17
|
+
For each variable, computes the cartesian product of all categories
|
|
18
|
+
across every classification (up to 6, mapped to d4–d9). The unit of
|
|
19
|
+
measure (``mc``/``mn``) is resolved with the following precedence:
|
|
20
|
+
|
|
21
|
+
1. The category's own ``unidade`` field (when not ``None``).
|
|
22
|
+
2. The variable's ``unidade`` field as a fallback.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
variaveis: Iterable of :class:`~sidra_fetcher.agregados.Variavel`.
|
|
26
|
+
classificacoes: Iterable of
|
|
27
|
+
:class:`~sidra_fetcher.agregados.Classificacao`.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
A list of :class:`~sidra_sql.models.Dimensao` instances,
|
|
31
|
+
one per (variavel, combination-of-categories) tuple.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
# Pre-build a list of (categoria_list,) per classificacao so that
|
|
35
|
+
# itertools.product can expand them correctly.
|
|
36
|
+
cats_per_classificacao: list[list[Categoria]] = [
|
|
37
|
+
classificacao.categorias for classificacao in classificacoes
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
# Pad slots d4–d9: the model supports up to 6 classifications.
|
|
41
|
+
MAX_CLASSIFICACOES = 6
|
|
42
|
+
|
|
43
|
+
for variavel in variaveis:
|
|
44
|
+
variavel_id = str(variavel.id)
|
|
45
|
+
variavel_nome = variavel.nome
|
|
46
|
+
unidade_id = None
|
|
47
|
+
unidade_nome = variavel.unidade
|
|
48
|
+
|
|
49
|
+
if not cats_per_classificacao:
|
|
50
|
+
# No classifications: yield one row per variable with null d4–d9.
|
|
51
|
+
yield dict(
|
|
52
|
+
mc=unidade_id,
|
|
53
|
+
mn=unidade_nome,
|
|
54
|
+
d2c=variavel_id,
|
|
55
|
+
d2n=variavel_nome,
|
|
56
|
+
d4c=None,
|
|
57
|
+
d4n=None,
|
|
58
|
+
d5c=None,
|
|
59
|
+
d5n=None,
|
|
60
|
+
d6c=None,
|
|
61
|
+
d6n=None,
|
|
62
|
+
d7c=None,
|
|
63
|
+
d7n=None,
|
|
64
|
+
d8c=None,
|
|
65
|
+
d8n=None,
|
|
66
|
+
d9c=None,
|
|
67
|
+
d9n=None,
|
|
68
|
+
)
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
# Cartesian product across all classifications.
|
|
72
|
+
for combo in itertools.product(*cats_per_classificacao):
|
|
73
|
+
# Resolve unit: first category that provides one wins;
|
|
74
|
+
# fall back to the variable's own unit.
|
|
75
|
+
for cat in combo:
|
|
76
|
+
if cat.unidade is not None:
|
|
77
|
+
unidade_nome = cat.unidade
|
|
78
|
+
break
|
|
79
|
+
|
|
80
|
+
# Map combo slots → d4…d9 (pad with None when fewer than 6).
|
|
81
|
+
padded = list(combo) + [None] * (MAX_CLASSIFICACOES - len(combo))
|
|
82
|
+
|
|
83
|
+
def _id(cat):
|
|
84
|
+
return str(cat.id) if cat is not None else None
|
|
85
|
+
|
|
86
|
+
def _nome(cat):
|
|
87
|
+
return cat.nome if cat is not None else None
|
|
88
|
+
|
|
89
|
+
yield dict(
|
|
90
|
+
mc=unidade_id,
|
|
91
|
+
mn=unidade_nome,
|
|
92
|
+
d2c=variavel_id,
|
|
93
|
+
d2n=variavel_nome,
|
|
94
|
+
d4c=_id(padded[0]),
|
|
95
|
+
d4n=_nome(padded[0]),
|
|
96
|
+
d5c=_id(padded[1]),
|
|
97
|
+
d5n=_nome(padded[1]),
|
|
98
|
+
d6c=_id(padded[2]),
|
|
99
|
+
d6n=_nome(padded[2]),
|
|
100
|
+
d7c=_id(padded[3]),
|
|
101
|
+
d7n=_nome(padded[3]),
|
|
102
|
+
d8c=_id(padded[4]),
|
|
103
|
+
d8n=_nome(padded[4]),
|
|
104
|
+
d9c=_id(padded[5]),
|
|
105
|
+
d9n=_nome(padded[5]),
|
|
106
|
+
)
|
sidra_sql/validator.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import tomllib
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Severity(Enum):
|
|
8
|
+
OK = "ok"
|
|
9
|
+
WARN = "warn"
|
|
10
|
+
ERROR = "error"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Issue:
|
|
15
|
+
severity: Severity
|
|
16
|
+
message: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class SectionReport:
|
|
21
|
+
title: str
|
|
22
|
+
issues: list[Issue] = field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
def ok(self, msg: str) -> None:
|
|
25
|
+
self.issues.append(Issue(Severity.OK, msg))
|
|
26
|
+
|
|
27
|
+
def warn(self, msg: str) -> None:
|
|
28
|
+
self.issues.append(Issue(Severity.WARN, msg))
|
|
29
|
+
|
|
30
|
+
def error(self, msg: str) -> None:
|
|
31
|
+
self.issues.append(Issue(Severity.ERROR, msg))
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def errors(self) -> list[Issue]:
|
|
35
|
+
return [i for i in self.issues if i.severity == Severity.ERROR]
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def warnings(self) -> list[Issue]:
|
|
39
|
+
return [i for i in self.issues if i.severity == Severity.WARN]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ValidationReport:
|
|
44
|
+
sections: list[SectionReport] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def error_count(self) -> int:
|
|
48
|
+
return sum(len(s.errors) for s in self.sections)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def warning_count(self) -> int:
|
|
52
|
+
return sum(len(s.warnings) for s in self.sections)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def is_valid(self) -> bool:
|
|
56
|
+
return self.error_count == 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PluginValidator:
|
|
60
|
+
def __init__(self, plugin_dir: Path):
|
|
61
|
+
self.plugin_dir = plugin_dir
|
|
62
|
+
|
|
63
|
+
def validate(self) -> ValidationReport:
|
|
64
|
+
report = ValidationReport()
|
|
65
|
+
|
|
66
|
+
manifest_section = SectionReport("manifest.toml")
|
|
67
|
+
report.sections.append(manifest_section)
|
|
68
|
+
|
|
69
|
+
manifest_path = self.plugin_dir / "manifest.toml"
|
|
70
|
+
if not manifest_path.exists():
|
|
71
|
+
manifest_section.error("manifest.toml não encontrado")
|
|
72
|
+
return report
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
with open(manifest_path, "rb") as f:
|
|
76
|
+
manifest = tomllib.load(f)
|
|
77
|
+
except tomllib.TOMLDecodeError as e:
|
|
78
|
+
manifest_section.error(f"TOML inválido: {e}")
|
|
79
|
+
return report
|
|
80
|
+
|
|
81
|
+
manifest_section.ok("TOML válido")
|
|
82
|
+
|
|
83
|
+
if "name" not in manifest:
|
|
84
|
+
manifest_section.warn("Campo 'name' ausente")
|
|
85
|
+
if "version" not in manifest:
|
|
86
|
+
manifest_section.warn("Campo 'version' ausente")
|
|
87
|
+
|
|
88
|
+
pipelines = manifest.get("pipeline", [])
|
|
89
|
+
if not pipelines:
|
|
90
|
+
manifest_section.warn("Nenhum [[pipeline]] declarado")
|
|
91
|
+
else:
|
|
92
|
+
manifest_section.ok(f"{len(pipelines)} pipeline(s) declarado(s)")
|
|
93
|
+
|
|
94
|
+
ids_seen: set[str] = set()
|
|
95
|
+
valid_pipelines: list[dict] = []
|
|
96
|
+
|
|
97
|
+
for i, p in enumerate(pipelines):
|
|
98
|
+
entry = f"pipeline[{i}]"
|
|
99
|
+
pid = p.get("id")
|
|
100
|
+
ppath = p.get("path")
|
|
101
|
+
|
|
102
|
+
if not pid:
|
|
103
|
+
manifest_section.error(f"{entry}: campo 'id' ausente")
|
|
104
|
+
continue
|
|
105
|
+
if not ppath:
|
|
106
|
+
manifest_section.error(f"pipeline '{pid}': campo 'path' ausente")
|
|
107
|
+
continue
|
|
108
|
+
if pid in ids_seen:
|
|
109
|
+
manifest_section.error(f"ID duplicado: '{pid}'")
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
ids_seen.add(pid)
|
|
113
|
+
valid_pipelines.append(p)
|
|
114
|
+
|
|
115
|
+
for p in valid_pipelines:
|
|
116
|
+
section = SectionReport(p["path"])
|
|
117
|
+
report.sections.append(section)
|
|
118
|
+
self._validate_pipeline(p["id"], p["path"], section)
|
|
119
|
+
|
|
120
|
+
return report
|
|
121
|
+
|
|
122
|
+
def _validate_pipeline(
|
|
123
|
+
self, pid: str, rel_path: str, section: SectionReport
|
|
124
|
+
) -> None:
|
|
125
|
+
pipeline_dir = self.plugin_dir / rel_path
|
|
126
|
+
|
|
127
|
+
if not pipeline_dir.exists():
|
|
128
|
+
section.error(f"Diretório não encontrado: '{pipeline_dir}'")
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
has_fetch = (pipeline_dir / "fetch.toml").exists()
|
|
132
|
+
has_transform = (pipeline_dir / "transform.toml").exists()
|
|
133
|
+
|
|
134
|
+
if not has_fetch and not has_transform:
|
|
135
|
+
section.error("Nenhum fetch.toml ou transform.toml encontrado")
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
if has_fetch:
|
|
139
|
+
self._validate_fetch_toml(pipeline_dir, section)
|
|
140
|
+
|
|
141
|
+
if has_transform:
|
|
142
|
+
self._validate_transform_toml(pipeline_dir, section)
|
|
143
|
+
|
|
144
|
+
def _validate_fetch_toml(self, pipeline_dir: Path, section: SectionReport) -> None:
|
|
145
|
+
fetch_path = pipeline_dir / "fetch.toml"
|
|
146
|
+
try:
|
|
147
|
+
with open(fetch_path, "rb") as f:
|
|
148
|
+
data = tomllib.load(f)
|
|
149
|
+
except tomllib.TOMLDecodeError as e:
|
|
150
|
+
section.error(f"fetch.toml: TOML inválido: {e}")
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
tabelas = data.get("tabelas", [])
|
|
154
|
+
if not tabelas:
|
|
155
|
+
section.error("fetch.toml: nenhuma [[tabelas]] declarada")
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
for i, t in enumerate(tabelas):
|
|
159
|
+
if "tabela_sidra" not in t:
|
|
160
|
+
section.error(f"fetch.toml: tabelas[{i}] sem campo 'tabela_sidra'")
|
|
161
|
+
|
|
162
|
+
section.ok(f"fetch.toml válido ({len(tabelas)} tabela(s))")
|
|
163
|
+
|
|
164
|
+
def _validate_transform_toml(
|
|
165
|
+
self, pipeline_dir: Path, section: SectionReport
|
|
166
|
+
) -> None:
|
|
167
|
+
transform_path = pipeline_dir / "transform.toml"
|
|
168
|
+
try:
|
|
169
|
+
with open(transform_path, "rb") as f:
|
|
170
|
+
data = tomllib.load(f)
|
|
171
|
+
except tomllib.TOMLDecodeError as e:
|
|
172
|
+
section.error(f"transform.toml: TOML inválido: {e}")
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
tables = data.get("table")
|
|
176
|
+
if isinstance(tables, dict):
|
|
177
|
+
section.error(
|
|
178
|
+
"transform.toml: schema [table] singular foi removido — "
|
|
179
|
+
"migre para [[table]] (array) com campo 'sql' por entrada"
|
|
180
|
+
)
|
|
181
|
+
return
|
|
182
|
+
if not isinstance(tables, list) or not tables:
|
|
183
|
+
section.error("transform.toml: nenhum [[table]] declarado")
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
seen: set[tuple[str, str]] = set()
|
|
187
|
+
any_error = False
|
|
188
|
+
for i, t in enumerate(tables):
|
|
189
|
+
entry = f"[[table]][{i}]"
|
|
190
|
+
missing = [f for f in ("name", "schema", "strategy", "sql") if f not in t]
|
|
191
|
+
if missing:
|
|
192
|
+
section.error(
|
|
193
|
+
f"transform.toml: {entry} sem campo(s) obrigatório(s): "
|
|
194
|
+
f"{', '.join(missing)}"
|
|
195
|
+
)
|
|
196
|
+
any_error = True
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
strategy = t["strategy"]
|
|
200
|
+
if strategy not in ("replace", "view"):
|
|
201
|
+
section.error(
|
|
202
|
+
f"transform.toml: {entry} strategy inválido: {strategy!r} "
|
|
203
|
+
"(esperado 'replace' ou 'view')"
|
|
204
|
+
)
|
|
205
|
+
any_error = True
|
|
206
|
+
|
|
207
|
+
key = (t["schema"], t["name"])
|
|
208
|
+
if key in seen:
|
|
209
|
+
section.error(f"transform.toml: saída duplicada {key[0]}.{key[1]}")
|
|
210
|
+
any_error = True
|
|
211
|
+
seen.add(key)
|
|
212
|
+
|
|
213
|
+
sql_path = pipeline_dir / t["sql"]
|
|
214
|
+
if not sql_path.exists():
|
|
215
|
+
section.error(
|
|
216
|
+
f"transform.toml: {entry} aponta para sql='{t['sql']}' "
|
|
217
|
+
"mas o arquivo não existe"
|
|
218
|
+
)
|
|
219
|
+
any_error = True
|
|
220
|
+
|
|
221
|
+
if not any_error:
|
|
222
|
+
section.ok(f"transform.toml válido ({len(tables)} saída(s))")
|