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