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/exporter.py ADDED
@@ -0,0 +1,171 @@
1
+ """Export materialized analytics outputs (and as-of vintage snapshots) to CSV.
2
+
3
+ Reads a pipeline's ``transform.toml`` to discover its ``[[table]]`` outputs and
4
+ streams each to a CSV file using the PostgreSQL ``COPY ... TO STDOUT`` protocol
5
+ (no DataFrame/Polars dependency).
6
+
7
+ Two modes:
8
+
9
+ * **current** — ``COPY (SELECT * FROM "<schema>"."<name>")`` for each
10
+ materialized analytics table/view (the current truth).
11
+ * **as-of** — reconstruct the outputs as IBGE published them on a date. A temp
12
+ view ``dados`` shadows the real fact table (``search_path`` puts ``pg_temp``
13
+ first), exposing, per logical key, the row with the greatest
14
+ ``modificacao <= date`` flagged ``ativo``. The pipeline's own transform
15
+ ``.sql`` runs against it unchanged — its ``WHERE ativo = true`` passes exactly
16
+ that vintage — and the result is streamed to CSV. Persisted ``analytics.*``
17
+ tables are left untouched.
18
+ """
19
+
20
+ import datetime as dt
21
+ import tomllib
22
+ from pathlib import Path
23
+
24
+ from rich.console import Console
25
+
26
+ from . import database
27
+ from .config import Config
28
+
29
+
30
+ def read_outputs(toml_path: Path) -> list[dict]:
31
+ """Return the ``[[table]]`` entries declared in a ``transform.toml``."""
32
+ with open(toml_path, "rb") as f:
33
+ data = tomllib.load(f)
34
+ tables = data.get("table")
35
+ if not isinstance(tables, list) or not tables:
36
+ raise ValueError(f"{toml_path}: esperado um ou mais [[table]] (array).")
37
+ for entry in tables:
38
+ missing = [k for k in ("name", "schema", "sql") if k not in entry]
39
+ if missing:
40
+ raise ValueError(
41
+ f"{toml_path}: [[table]] sem campo(s) obrigatório(s): "
42
+ f"{', '.join(missing)}"
43
+ )
44
+ return tables
45
+
46
+
47
+ def _clean_query(sql: str) -> str:
48
+ """Strip trailing whitespace/semicolons so the SELECT can be embedded."""
49
+ return sql.strip().rstrip(";").strip()
50
+
51
+
52
+ def copy_table_sql(schema: str, name: str) -> str:
53
+ """COPY that dumps a materialized table/view to CSV on STDOUT."""
54
+ return (
55
+ f'COPY (SELECT * FROM "{schema}"."{name}") TO STDOUT WITH (FORMAT csv, HEADER)'
56
+ )
57
+
58
+
59
+ def copy_query_sql(query: str) -> str:
60
+ """COPY that dumps an arbitrary SELECT to CSV on STDOUT."""
61
+ return f"COPY ({_clean_query(query)}) TO STDOUT WITH (FORMAT csv, HEADER)"
62
+
63
+
64
+ def asof_view_sql(schema: str, asof: dt.date) -> str:
65
+ """``CREATE TEMP VIEW dados`` exposing the as-of vintage as the active rows.
66
+
67
+ Per logical key, picks the row with the greatest ``modificacao <= asof`` and
68
+ flags it ``ativo``, so transforms filtering ``WHERE ativo = true`` see the
69
+ data exactly as published on that date. The inner source is
70
+ schema-qualified so the temp view does not reference itself. The date is a
71
+ validated ``date`` object, inlined as a literal (no parameters allowed in a
72
+ view definition).
73
+ """
74
+ return (
75
+ "CREATE TEMP VIEW dados AS"
76
+ " SELECT id, tabela_sidra_id, dimensao_id, localidade_id, periodo_id,"
77
+ " modificacao, TRUE AS ativo, v FROM ("
78
+ " SELECT DISTINCT ON"
79
+ " (tabela_sidra_id, localidade_id, dimensao_id, periodo_id)"
80
+ " id, tabela_sidra_id, dimensao_id, localidade_id, periodo_id,"
81
+ " modificacao, v"
82
+ f' FROM "{schema}".dados'
83
+ f" WHERE modificacao <= DATE '{asof.isoformat()}'"
84
+ " ORDER BY tabela_sidra_id, localidade_id, dimensao_id, periodo_id,"
85
+ " modificacao DESC"
86
+ " ) AS _asof"
87
+ )
88
+
89
+
90
+ def stamped_name(name: str, asof: dt.date | None) -> str:
91
+ """``name.csv`` for the current truth, ``name@YYYYMMDD.csv`` for as-of."""
92
+ if asof is None:
93
+ return f"{name}.csv"
94
+ return f"{name}@{asof:%Y%m%d}.csv"
95
+
96
+
97
+ class Exporter:
98
+ """Export a pipeline's analytics outputs to CSV files."""
99
+
100
+ def __init__(
101
+ self,
102
+ config: Config,
103
+ toml_path: Path,
104
+ console: Console | None = None,
105
+ ):
106
+ self.config = config
107
+ self.toml_path = toml_path
108
+ self.console = console
109
+
110
+ def export(self, output_dir: Path, asof: dt.date | None = None) -> list[Path]:
111
+ """Export every declared output; return the paths actually written."""
112
+ outputs = read_outputs(self.toml_path)
113
+ output_dir.mkdir(parents=True, exist_ok=True)
114
+ engine = database.get_engine(self.config)
115
+ written: list[Path] = []
116
+
117
+ with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
118
+ pg_conn = conn.connection.driver_connection
119
+ cur = pg_conn.cursor()
120
+
121
+ if asof is not None:
122
+ # pg_temp first so the temp view shadows the real `dados`.
123
+ cur.execute(f"SET search_path = pg_temp, {self.config.db_schema}")
124
+ cur.execute(asof_view_sql(self.config.db_schema, asof))
125
+
126
+ for entry in outputs:
127
+ name = entry["name"]
128
+ schema = entry["schema"]
129
+ out_path = output_dir / stamped_name(name, asof)
130
+
131
+ if asof is None:
132
+ sql = copy_table_sql(schema, name)
133
+ else:
134
+ query = (self.toml_path.parent / entry["sql"]).read_text(
135
+ encoding="utf-8"
136
+ )
137
+ sql = copy_query_sql(query)
138
+
139
+ try:
140
+ self._copy_to_file(cur, sql, out_path)
141
+ except Exception as exc: # noqa: BLE001
142
+ self._warn(name, schema, asof, exc)
143
+ continue
144
+
145
+ written.append(out_path)
146
+ if self.console is not None:
147
+ self.console.print(f" [green]✓[/green] {out_path}")
148
+
149
+ return written
150
+
151
+ @staticmethod
152
+ def _copy_to_file(cur, sql: str, out_path: Path) -> None:
153
+ with cur.copy(sql) as copy:
154
+ with open(out_path, "wb") as fh:
155
+ for chunk in copy:
156
+ fh.write(chunk)
157
+
158
+ def _warn(
159
+ self, name: str, schema: str, asof: dt.date | None, exc: Exception
160
+ ) -> None:
161
+ if self.console is None:
162
+ return
163
+ if asof is None:
164
+ self.console.print(
165
+ f" [yellow]![/yellow] {schema}.{name} não encontrada — "
166
+ "rode 'sidra-sql run' para materializar. (pulando)"
167
+ )
168
+ else:
169
+ self.console.print(
170
+ f" [yellow]![/yellow] falha ao exportar {name} as-of: {exc}"
171
+ )
sidra_sql/models.py ADDED
@@ -0,0 +1,230 @@
1
+ import datetime as dt
2
+
3
+ import sqlalchemy as sa
4
+ from sqlalchemy import (
5
+ BigInteger,
6
+ Boolean,
7
+ CheckConstraint,
8
+ Date,
9
+ DateTime,
10
+ ForeignKey,
11
+ Identity,
12
+ Integer,
13
+ SmallInteger,
14
+ Text,
15
+ UniqueConstraint,
16
+ func,
17
+ )
18
+ from sqlalchemy.dialects.postgresql import ARRAY, JSONB
19
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
20
+
21
+
22
+ class Base(DeclarativeBase):
23
+ pass
24
+
25
+
26
+ class TabelaSidra(Base):
27
+ __tablename__ = "tabela_sidra"
28
+ id: Mapped[str] = mapped_column(Text, primary_key=True)
29
+ nome: Mapped[str] = mapped_column(Text, nullable=False)
30
+ periodicidade: Mapped[str] = mapped_column(Text, nullable=False)
31
+ ultima_atualizacao: Mapped[Date] = mapped_column(
32
+ Date,
33
+ nullable=False,
34
+ default=func.now(),
35
+ onupdate=func.now(),
36
+ )
37
+ metadados: Mapped[JSONB] = mapped_column(JSONB, nullable=True)
38
+ dados = relationship("Dados", back_populates="tabela_sidra")
39
+
40
+
41
+ class Localidade(Base):
42
+ __tablename__ = "localidade"
43
+ __table_args__ = (
44
+ UniqueConstraint(
45
+ "nc",
46
+ "d1c",
47
+ name="uq_localidade",
48
+ ),
49
+ )
50
+
51
+ id: Mapped[int] = mapped_column(
52
+ BigInteger,
53
+ Identity(always=True),
54
+ primary_key=True,
55
+ )
56
+ dados = relationship("Dados", back_populates="localidade")
57
+ # NC = NIVEL TERRITORIAL ID
58
+ nc: Mapped[str] = mapped_column(Text, nullable=False)
59
+ # NN = NIVEL TERRITORIAL NOME
60
+ nn: Mapped[str] = mapped_column(Text, nullable=False)
61
+ # D1C = UNIDADE TERRITORIAL ID
62
+ d1c: Mapped[str] = mapped_column(Text, nullable=False)
63
+ # D1N = UNIDADE TERRITORIAL NOME
64
+ d1n: Mapped[str] = mapped_column(Text, nullable=False)
65
+
66
+
67
+ class Periodo(Base):
68
+ __tablename__ = "periodo"
69
+ __table_args__ = (
70
+ UniqueConstraint(
71
+ "codigo",
72
+ "literals",
73
+ name="uq_periodo",
74
+ postgresql_nulls_not_distinct=True,
75
+ ),
76
+ )
77
+
78
+ id: Mapped[int] = mapped_column(Integer, Identity(always=True), primary_key=True)
79
+ codigo: Mapped[str] = mapped_column(Text, nullable=False)
80
+ frequencia: Mapped[str | None] = mapped_column(Text)
81
+ literals: Mapped[list[str] | None] = mapped_column(ARRAY(Text))
82
+ data_inicio: Mapped[dt.date | None] = mapped_column(Date)
83
+ data_fim: Mapped[dt.date | None] = mapped_column(Date)
84
+ ano: Mapped[int | None] = mapped_column(Integer)
85
+ ano_fim: Mapped[int | None] = mapped_column(Integer)
86
+ semestre: Mapped[int | None] = mapped_column(
87
+ SmallInteger,
88
+ CheckConstraint("semestre IN (1, 2)"),
89
+ )
90
+ trimestre: Mapped[int | None] = mapped_column(
91
+ SmallInteger,
92
+ CheckConstraint("trimestre IN (1, 2, 3, 4)"),
93
+ )
94
+ mes: Mapped[int | None] = mapped_column(
95
+ SmallInteger,
96
+ CheckConstraint("mes IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)"),
97
+ )
98
+ dados = relationship("Dados", back_populates="periodo")
99
+
100
+
101
+ class Dimensao(Base):
102
+ __tablename__ = "dimensao"
103
+ __table_args__ = (
104
+ UniqueConstraint(
105
+ "mc",
106
+ "d2c",
107
+ "d4c",
108
+ "d5c",
109
+ "d6c",
110
+ "d7c",
111
+ "d8c",
112
+ "d9c",
113
+ name="uq_dimensao",
114
+ postgresql_nulls_not_distinct=True,
115
+ ),
116
+ )
117
+
118
+ id: Mapped[int] = mapped_column(
119
+ BigInteger,
120
+ Identity(always=True),
121
+ primary_key=True,
122
+ )
123
+ dados = relationship("Dados", back_populates="dimensao")
124
+ # MC = UNIDADE ID
125
+ mc: Mapped[str] = mapped_column(Text, nullable=True)
126
+ # MN = UNIDADE NOME
127
+ mn: Mapped[str] = mapped_column(Text, nullable=False)
128
+ # D2C = VARIAVEL ID
129
+ d2c: Mapped[str] = mapped_column(Text, nullable=False)
130
+ # D2N = VARIAVEL NOME
131
+ d2n: Mapped[str] = mapped_column(Text, nullable=False)
132
+ # D4C = CATEGORIA ID da Classificação 1
133
+ d4c: Mapped[str] = mapped_column(Text, nullable=True)
134
+ # D4N = CATEGORIA NOME da Classificação 1
135
+ d4n: Mapped[str] = mapped_column(Text, nullable=True)
136
+ # D5C = CATEGORIA ID da Classificação 2
137
+ d5c: Mapped[str] = mapped_column(Text, nullable=True)
138
+ # D5N = CATEGORIA NOME da Classificação 2
139
+ d5n: Mapped[str] = mapped_column(Text, nullable=True)
140
+ # D6C = CATEGORIA ID da Classificação 3
141
+ d6c: Mapped[str] = mapped_column(Text, nullable=True)
142
+ # D6N = CATEGORIA NOME da Classificação 3
143
+ d6n: Mapped[str] = mapped_column(Text, nullable=True)
144
+ # D7C = CATEGORIA ID da Classificação 4
145
+ d7c: Mapped[str] = mapped_column(Text, nullable=True)
146
+ # D7N = CATEGORIA NOME da Classificação 4
147
+ d7n: Mapped[str] = mapped_column(Text, nullable=True)
148
+ # D8C = CATEGORIA ID da Classificação 5
149
+ d8c: Mapped[str] = mapped_column(Text, nullable=True)
150
+ # D8N = CATEGORIA NOME da Classificação 5
151
+ d8n: Mapped[str] = mapped_column(Text, nullable=True)
152
+ # D9C = CATEGORIA ID da Classificação 6
153
+ d9c: Mapped[str] = mapped_column(Text, nullable=True)
154
+ # D9N = CATEGORIA NOME da Classificação 6
155
+ d9n: Mapped[str] = mapped_column(Text, nullable=True)
156
+
157
+
158
+ class ArquivoCarregado(Base):
159
+ __tablename__ = "arquivo_carregado"
160
+
161
+ arquivo: Mapped[str] = mapped_column(Text, primary_key=True)
162
+ tabela_sidra_id: Mapped[str] = mapped_column(
163
+ ForeignKey("tabela_sidra.id"), nullable=False, index=True
164
+ )
165
+ carregado_em: Mapped[dt.datetime] = mapped_column(
166
+ DateTime(timezone=True),
167
+ nullable=False,
168
+ server_default=func.now(),
169
+ )
170
+
171
+
172
+ class Dados(Base):
173
+ __tablename__ = "dados"
174
+ __table_args__ = (
175
+ sa.Index("ix_dados_periodo", "tabela_sidra_id", "periodo_id"),
176
+ # Vintage storage: várias revisões (modificacao) coexistem por chave,
177
+ # no máximo uma ativa. O índice único parcial WHERE ativo garante essa
178
+ # invariante; as 4 colunas são NOT NULL, então não precisa de
179
+ # NULLS NOT DISTINCT (sem requisito de PostgreSQL >= 15).
180
+ sa.Index(
181
+ "uq_dados_ativo",
182
+ "tabela_sidra_id",
183
+ "localidade_id",
184
+ "dimensao_id",
185
+ "periodo_id",
186
+ unique=True,
187
+ postgresql_where=sa.text("ativo"),
188
+ ),
189
+ # Apoio à reconstrução as-of: a linha de maior modificacao <= data.
190
+ sa.Index(
191
+ "ix_dados_asof",
192
+ "tabela_sidra_id",
193
+ "periodo_id",
194
+ "modificacao",
195
+ ),
196
+ )
197
+
198
+ id: Mapped[int] = mapped_column(
199
+ BigInteger,
200
+ Identity(always=True),
201
+ primary_key=True,
202
+ )
203
+ tabela_sidra_id: Mapped[str] = mapped_column(
204
+ ForeignKey("tabela_sidra.id"),
205
+ nullable=False,
206
+ index=True,
207
+ )
208
+ tabela_sidra = relationship("TabelaSidra", back_populates="dados")
209
+ dimensao_id: Mapped[int] = mapped_column(
210
+ ForeignKey("dimensao.id"),
211
+ nullable=False,
212
+ index=True,
213
+ )
214
+ dimensao = relationship("Dimensao", back_populates="dados")
215
+ localidade_id: Mapped[int] = mapped_column(
216
+ ForeignKey("localidade.id"),
217
+ nullable=False,
218
+ index=True,
219
+ )
220
+ localidade = relationship("Localidade", back_populates="dados")
221
+ periodo_id: Mapped[int] = mapped_column(
222
+ ForeignKey("periodo.id"),
223
+ nullable=False,
224
+ index=True,
225
+ )
226
+ periodo = relationship("Periodo", back_populates="dados")
227
+ modificacao: Mapped[Date] = mapped_column(Date, nullable=False)
228
+ ativo: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
229
+ # VALOR
230
+ v: Mapped[str] = mapped_column(Text, nullable=False)
@@ -0,0 +1,200 @@
1
+ import json
2
+ import logging
3
+ import shutil
4
+ import subprocess
5
+ import tomllib
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ import platformdirs
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ APP_NAME = "sidra-sql"
14
+ DEFAULT_PLUGIN_URL = "https://github.com/Quantilica/sidra-pipelines.git"
15
+ DEFAULT_PLUGIN_ALIAS = "std"
16
+
17
+
18
+ @dataclass
19
+ class PipelineDef:
20
+ id: str
21
+ description: str
22
+ path: Path
23
+
24
+
25
+ @dataclass
26
+ class PluginManifest:
27
+ name: str
28
+ description: str
29
+ version: str
30
+ pipelines: list[PipelineDef]
31
+
32
+
33
+ class PluginRegistry:
34
+ def __init__(self):
35
+ self.config_dir = Path(platformdirs.user_config_dir(APP_NAME, appauthor=False))
36
+ self.data_dir = Path(platformdirs.user_data_dir(APP_NAME, appauthor=False))
37
+ self.plugins_dir = self.data_dir / "plugins"
38
+ self.registry_file = self.config_dir / "registry.json"
39
+
40
+ self.config_dir.mkdir(parents=True, exist_ok=True)
41
+ self.plugins_dir.mkdir(parents=True, exist_ok=True)
42
+
43
+ if not self.registry_file.exists():
44
+ self._save_registry({})
45
+
46
+ def _load_registry(self) -> dict:
47
+ with open(self.registry_file, encoding="utf-8") as f:
48
+ return json.load(f)
49
+
50
+ def _save_registry(self, data: dict):
51
+ with open(self.registry_file, "w", encoding="utf-8") as f:
52
+ json.dump(data, f, indent=4)
53
+
54
+ def get_plugins(self) -> dict:
55
+ return self._load_registry()
56
+
57
+ def get_plugin_path(self, alias: str) -> Path:
58
+ return self.plugins_dir / alias
59
+
60
+ def register_plugin(self, alias: str, url: str):
61
+ registry = self._load_registry()
62
+ registry[alias] = {"url": url}
63
+ self._save_registry(registry)
64
+
65
+ def remove_plugin(self, alias: str):
66
+ registry = self._load_registry()
67
+ if alias in registry:
68
+ del registry[alias]
69
+ self._save_registry(registry)
70
+
71
+
72
+ class PluginManager:
73
+ def __init__(self):
74
+ self.registry = PluginRegistry()
75
+
76
+ def _check_git(self):
77
+ """Verifica se o Git está instalado."""
78
+ if shutil.which("git") is None:
79
+ raise RuntimeError(
80
+ "Git não encontrado. O Git é necessário para gerenciar e "
81
+ "baixar plugins. Por favor, instale o Git "
82
+ "(https://git-scm.com/) e tente novamente."
83
+ )
84
+
85
+ def install(self, url: str, alias: str | None = None):
86
+ self._check_git()
87
+ if not alias:
88
+ # simple alias extraction from url
89
+ alias = url.rstrip("/").split("/")[-1]
90
+ if alias.endswith(".git"):
91
+ alias = alias[:-4]
92
+
93
+ plugin_path = self.registry.get_plugin_path(alias)
94
+ if plugin_path.exists():
95
+ raise ValueError(f"Plugin with alias '{alias}' is already installed.")
96
+
97
+ logger.info("Cloning %s into %s", url, plugin_path)
98
+ subprocess.run(["git", "clone", url, str(plugin_path)], check=True)
99
+ self.registry.register_plugin(alias, url)
100
+ logger.info("Plugin '%s' installed successfully.", alias)
101
+
102
+ def update(self, alias: str | None = None):
103
+ self._check_git()
104
+ plugins = self.registry.get_plugins()
105
+ target_aliases = [alias] if alias else list(plugins.keys())
106
+
107
+ for target in target_aliases:
108
+ if target not in plugins:
109
+ logger.warning("Plugin '%s' not found in registry.", target)
110
+ continue
111
+
112
+ plugin_path = self.registry.get_plugin_path(target)
113
+ if not plugin_path.exists():
114
+ logger.warning("Plugin directory for '%s' is missing.", target)
115
+ continue
116
+
117
+ logger.info("Updating plugin '%s'", target)
118
+ subprocess.run(["git", "pull"], cwd=plugin_path, check=True)
119
+
120
+ def remove(self, alias: str):
121
+ plugin_path = self.registry.get_plugin_path(alias)
122
+ if plugin_path.exists():
123
+ logger.info("Removing directory %s", plugin_path)
124
+
125
+ # Use shutil on windows/linux to deal with read-only files
126
+ # sometimes created by git
127
+ def handle_remove_readonly(func, path, exc):
128
+ import os
129
+ import stat
130
+
131
+ os.chmod(path, stat.S_IWRITE)
132
+ func(path)
133
+
134
+ shutil.rmtree(plugin_path, onerror=handle_remove_readonly)
135
+
136
+ self.registry.remove_plugin(alias)
137
+ logger.info("Plugin '%s' removed successfully.", alias)
138
+
139
+ def ensure_defaults(self):
140
+ """Garante que o plugin padrão esteja instalado."""
141
+ plugins = self.registry.get_plugins()
142
+ if DEFAULT_PLUGIN_ALIAS not in plugins:
143
+ try:
144
+ self._check_git()
145
+ logger.info("Instalando pipelines padrão...")
146
+ self.install(DEFAULT_PLUGIN_URL, alias=DEFAULT_PLUGIN_ALIAS)
147
+ except Exception as e:
148
+ # Silenciosamente falha se não houver internet no bootstrap,
149
+ # permitindo que o usuário use o CLI de qualquer forma.
150
+ logger.debug(f"Falha ao instalar pipelines padrão: {e}")
151
+
152
+ def read_manifest(self, alias: str) -> PluginManifest:
153
+ plugin_path = self.registry.get_plugin_path(alias)
154
+ manifest_path = plugin_path / "manifest.toml"
155
+
156
+ if not manifest_path.exists():
157
+ raise FileNotFoundError(
158
+ f"Manifest not found for plugin '{alias}' at {manifest_path}"
159
+ )
160
+
161
+ with open(manifest_path, "rb") as f:
162
+ data = tomllib.load(f)
163
+
164
+ pipelines = []
165
+ for p in data.get("pipeline", []):
166
+ pipelines.append(
167
+ PipelineDef(
168
+ id=p["id"],
169
+ description=p.get("description", ""),
170
+ path=plugin_path / p["path"],
171
+ )
172
+ )
173
+
174
+ return PluginManifest(
175
+ name=data.get("name", alias),
176
+ description=data.get("description", ""),
177
+ version=data.get("version", "unknown"),
178
+ pipelines=pipelines,
179
+ )
180
+
181
+ def list_pipelines(self) -> list[tuple[str, str, PipelineDef]]:
182
+ plugins = self.registry.get_plugins()
183
+ all_pipelines = []
184
+
185
+ for alias in plugins:
186
+ try:
187
+ manifest = self.read_manifest(alias)
188
+ for p in manifest.pipelines:
189
+ all_pipelines.append((alias, manifest.name, p))
190
+ except Exception as e:
191
+ logger.warning("Could not load manifest for plugin '%s': %s", alias, e)
192
+
193
+ return all_pipelines
194
+
195
+ def get_pipeline(self, alias: str, pipeline_id: str) -> PipelineDef:
196
+ manifest = self.read_manifest(alias)
197
+ for p in manifest.pipelines:
198
+ if p.id == pipeline_id:
199
+ return p
200
+ raise ValueError(f"Pipeline '{pipeline_id}' not found in plugin '{alias}'")
sidra_sql/runner.py ADDED
@@ -0,0 +1,76 @@
1
+ """Hierarchical pipeline runner.
2
+
3
+ Walks a pipeline directory depth-first, post-order:
4
+ each child subtree (any subdirectory containing ``fetch.toml`` or
5
+ ``transform.toml``) runs to completion before the parent's own
6
+ ``fetch.toml`` / ``transform.toml`` execute. This lets a parent's
7
+ SQL transform consume the materialized outputs of its children.
8
+ """
9
+
10
+ import logging
11
+ import time
12
+ from pathlib import Path
13
+
14
+ from rich.console import Console
15
+
16
+ from .config import Config
17
+ from .toml_runner import TomlScript
18
+ from .transform_runner import TransformRunner
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def _is_pipeline_dir(p: Path) -> bool:
24
+ return p.is_dir() and (
25
+ (p / "fetch.toml").exists() or (p / "transform.toml").exists()
26
+ )
27
+
28
+
29
+ def run_subtree(
30
+ config: Config,
31
+ path: Path,
32
+ force_metadata: bool = False,
33
+ force_load: bool = False,
34
+ console: Console | None = None,
35
+ ):
36
+ """Run all sub-pipelines under ``path`` post-order, then ``path`` itself."""
37
+ if not path.exists() or not path.is_dir():
38
+ raise FileNotFoundError(f"Pipeline directory not found: {path}")
39
+
40
+ for child in sorted(path.iterdir()):
41
+ if _is_pipeline_dir(child):
42
+ run_subtree(config, child, force_metadata, force_load, console)
43
+
44
+ fetch_path = path / "fetch.toml"
45
+ transform_path = path / "transform.toml"
46
+
47
+ if fetch_path.exists():
48
+ if console:
49
+ console.rule(f"[bold cyan]fetch[/bold cyan] {path.name}", style="cyan dim")
50
+ t0 = time.monotonic()
51
+ TomlScript(
52
+ config,
53
+ fetch_path,
54
+ force_metadata=force_metadata,
55
+ force_load=force_load,
56
+ console=console,
57
+ ).run()
58
+ if console:
59
+ elapsed = time.monotonic() - t0
60
+ console.print(
61
+ f" [green]✓[/green] fetch concluído em [bold]{elapsed:.1f}s[/bold]"
62
+ )
63
+
64
+ if transform_path.exists():
65
+ if console:
66
+ console.rule(
67
+ f"[bold magenta]transform[/bold magenta] {path.name}",
68
+ style="magenta dim",
69
+ )
70
+ t0 = time.monotonic()
71
+ TransformRunner(config, transform_path, console=console).run()
72
+ if console:
73
+ elapsed = time.monotonic() - t0
74
+ console.print(
75
+ f" [green]✓[/green] transform concluído em [bold]{elapsed:.1f}s[/bold]"
76
+ )