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