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 ADDED
@@ -0,0 +1,15 @@
1
+ import logging
2
+ from importlib.metadata import PackageNotFoundError, version
3
+
4
+ from . import config, database, sidra, storage
5
+
6
+ try:
7
+ __version__ = version("sidra-sql")
8
+ except PackageNotFoundError:
9
+ __version__ = "0.0.0"
10
+
11
+ __all__ = ["config", "database", "sidra", "storage"]
12
+
13
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
14
+
15
+ config.setup_logging(__name__, "sidra-sql.log")
sidra_sql/cli.py ADDED
@@ -0,0 +1,631 @@
1
+ import configparser
2
+ import datetime as dt
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ import typer
7
+ from rich.align import Align
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.table import Table
11
+ from rich.text import Text
12
+
13
+ from sidra_sql import __version__
14
+ from sidra_sql.config import (
15
+ GLOBAL_CONFIG_PATH,
16
+ LOCAL_CONFIG_PATH,
17
+ Config,
18
+ ConfigError,
19
+ )
20
+ from sidra_sql.exporter import Exporter
21
+ from sidra_sql.plugin_manager import PluginManager
22
+ from sidra_sql.runner import run_subtree
23
+ from sidra_sql.scaffold import PipelineAdder, PluginScaffolder
24
+ from sidra_sql.transform_runner import TransformRunner
25
+ from sidra_sql.validator import PluginValidator, Severity
26
+
27
+ app = typer.Typer(help=f"Sidra-SQL CLI v{__version__} - Manage and run data pipelines")
28
+ plugin_app = typer.Typer(help="Manage pipeline plugins")
29
+ config_app = typer.Typer(help="Manage sidra-sql configuration")
30
+ app.add_typer(plugin_app, name="plugin")
31
+ app.add_typer(config_app, name="config")
32
+
33
+ console = Console()
34
+ manager = PluginManager()
35
+
36
+
37
+ def _print_header() -> None:
38
+ content = Align.center(
39
+ Text.assemble(
40
+ ("sidra-sql", "bold cyan"),
41
+ (f" v{__version__}", "dim"),
42
+ "\n",
43
+ ("Tabelas de dados agregados do IBGE", "dim"),
44
+ )
45
+ )
46
+ console.print(Panel(content, border_style="cyan dim", padding=(0, 0)))
47
+ console.print()
48
+
49
+
50
+ def _config_path(use_global: bool) -> Path:
51
+ return GLOBAL_CONFIG_PATH if use_global else LOCAL_CONFIG_PATH
52
+
53
+
54
+ def _read_config(path: Path) -> configparser.ConfigParser:
55
+ cfg = configparser.ConfigParser()
56
+ if path.exists():
57
+ cfg.read(path)
58
+ return cfg
59
+
60
+
61
+ def _write_config(cfg: configparser.ConfigParser, path: Path) -> None:
62
+ path.parent.mkdir(parents=True, exist_ok=True)
63
+ with open(path, "w") as f:
64
+ cfg.write(f)
65
+
66
+
67
+ @config_app.command("set")
68
+ def config_set(
69
+ key: str = typer.Argument(
70
+ ..., help="Config key in section.option format (e.g. database.host)"
71
+ ),
72
+ value: str = typer.Argument(..., help="Value to set"),
73
+ use_global: bool = typer.Option(
74
+ False,
75
+ "--global",
76
+ help="Write to global config (~/.config/quantilica/sidra-sql/config.ini)",
77
+ ),
78
+ ):
79
+ """Set a configuration value."""
80
+ if "." not in key:
81
+ console.print(
82
+ "[bold red]Error:[/bold red] key must be in 'section.option' "
83
+ "format (e.g. database.host)"
84
+ )
85
+ raise typer.Exit(1)
86
+
87
+ section, option = key.split(".", 1)
88
+ path = _config_path(use_global)
89
+ cfg = _read_config(path)
90
+
91
+ if not cfg.has_section(section):
92
+ cfg.add_section(section)
93
+ cfg.set(section, option, value)
94
+ _write_config(cfg, path)
95
+
96
+ scope = "global" if use_global else "local"
97
+ console.print(f"[green]Set[/green] {key} = {value} ([dim]{scope}[/dim])")
98
+
99
+
100
+ @config_app.command("get")
101
+ def config_get(
102
+ key: str = typer.Argument(
103
+ ..., help="Config key in section.option format (e.g. database.host)"
104
+ ),
105
+ ):
106
+ """Get a configuration value (local overrides global)."""
107
+ if "." not in key:
108
+ console.print(
109
+ "[bold red]Error:[/bold red] key must be in 'section.option' "
110
+ "format (e.g. database.host)"
111
+ )
112
+ raise typer.Exit(1)
113
+
114
+ section, option = key.split(".", 1)
115
+ cfg = configparser.ConfigParser()
116
+ cfg.read([GLOBAL_CONFIG_PATH, LOCAL_CONFIG_PATH])
117
+
118
+ if not cfg.has_option(section, option):
119
+ console.print(f"[yellow]Key not found:[/yellow] {key}")
120
+ raise typer.Exit(1)
121
+
122
+ console.print(cfg.get(section, option))
123
+
124
+
125
+ @config_app.command("list")
126
+ def config_list(
127
+ use_global: bool = typer.Option(False, "--global", help="Show only global config"),
128
+ local: bool = typer.Option(False, "--local", help="Show only local config"),
129
+ ):
130
+ """List configuration values.
131
+
132
+ Without flags, shows merged view (local overrides global).
133
+ """
134
+ if use_global:
135
+ paths = [GLOBAL_CONFIG_PATH]
136
+ label = "Global config"
137
+ elif local:
138
+ paths = [LOCAL_CONFIG_PATH]
139
+ label = "Local config"
140
+ else:
141
+ paths = [GLOBAL_CONFIG_PATH, LOCAL_CONFIG_PATH]
142
+ label = "Merged config (global + local)"
143
+
144
+ cfg = configparser.ConfigParser()
145
+ cfg.read(paths)
146
+
147
+ if not cfg.sections():
148
+ console.print("[yellow]No configuration found.[/yellow]")
149
+ return
150
+
151
+ table = Table(title=label, show_header=True, header_style="bold cyan")
152
+ table.add_column("Section")
153
+ table.add_column("Option")
154
+ table.add_column("Value")
155
+
156
+ for section in cfg.sections():
157
+ for option, value in cfg.items(section):
158
+ display = value if option != "password" else "*" * len(value)
159
+ table.add_row(section, option, display)
160
+
161
+ console.print(table)
162
+
163
+
164
+ def _version_callback(value: bool):
165
+ if value:
166
+ console.print(f"sidra-sql {__version__}")
167
+ raise typer.Exit()
168
+
169
+
170
+ @app.callback()
171
+ def bootstrap(
172
+ version: bool | None = typer.Option(
173
+ None,
174
+ "--version",
175
+ "-V",
176
+ callback=_version_callback,
177
+ is_eager=True,
178
+ help="Exibe a versão e encerra.",
179
+ ),
180
+ ):
181
+ """Inicialização automática do sistema."""
182
+ manager.ensure_defaults()
183
+
184
+
185
+ @plugin_app.command("install")
186
+ def install_plugin(
187
+ url: str,
188
+ alias: str | None = typer.Option(None, help="Alias for the plugin"),
189
+ ):
190
+ """Install a new plugin from a Git URL."""
191
+ try:
192
+ manager.install(url, alias)
193
+ console.print("[green]Plugin installed successfully.[/green]")
194
+ except Exception as e:
195
+ console.print(f"[red]Error installing plugin:[/red] {e}")
196
+
197
+
198
+ @plugin_app.command("update")
199
+ def update_plugin(
200
+ alias: str | None = typer.Argument(
201
+ None, help="Alias of the plugin to update (updates all if omitted)"
202
+ ),
203
+ ):
204
+ """Update installed plugin(s) from Git."""
205
+ try:
206
+ manager.update(alias)
207
+ console.print("[green]Update completed.[/green]")
208
+ except Exception as e:
209
+ console.print(f"[red]Error updating plugin:[/red] {e}")
210
+
211
+
212
+ @plugin_app.command("remove")
213
+ def remove_plugin(
214
+ alias: str = typer.Argument(..., help="Alias of the plugin to remove"),
215
+ ):
216
+ """Remove an installed plugin."""
217
+ try:
218
+ manager.remove(alias)
219
+ console.print(f"[green]Plugin '{alias}' removed.[/green]")
220
+ except Exception as e:
221
+ console.print(f"[red]Error removing plugin:[/red] {e}")
222
+
223
+
224
+ @plugin_app.command("scaffold")
225
+ def scaffold_plugin(
226
+ name: str = typer.Argument(..., help="Nome do plugin (vira o diretório raiz)"),
227
+ description: str = typer.Option(
228
+ "", "--description", "-d", help="Descrição do plugin"
229
+ ),
230
+ version: str = typer.Option("1.0.0", "--version", help="Versão semântica"),
231
+ output_dir: Path = typer.Option(
232
+ Path("."), "--output-dir", "-o", help="Diretório de saída"
233
+ ),
234
+ git_init: bool = typer.Option(
235
+ True, "--git-init/--no-git-init", help="Inicializar repositório Git"
236
+ ),
237
+ ):
238
+ """Cria a estrutura de arquivos para um novo plugin com templates prontos."""
239
+ try:
240
+ scaffolder = PluginScaffolder(name, description, version, output_dir, git_init)
241
+ plugin_dir = scaffolder.create()
242
+ slug = scaffolder.slug
243
+
244
+ console.print(
245
+ f"\n[bold green]Plugin '{name}' criado em {plugin_dir}[/bold green]\n"
246
+ )
247
+ console.print(" manifest.toml")
248
+ console.print(f" {slug}/")
249
+ console.print(" fetch.toml")
250
+ console.print(" transform.toml")
251
+ console.print(f" {slug}.sql")
252
+ console.print(" README.md")
253
+ if git_init:
254
+ console.print(" .gitignore")
255
+
256
+ console.print("\n[bold]Próximos passos:[/bold]")
257
+ console.print(
258
+ " 1. Edite [cyan]manifest.toml[/cyan] e ajuste a descrição do pipeline"
259
+ )
260
+ console.print(
261
+ f" 2. Em [cyan]{slug}/fetch.toml[/cyan], substitua XXXX pelo "
262
+ "ID da tabela SIDRA"
263
+ )
264
+ console.print(
265
+ f" 3. Ajuste [cyan]{slug}/{slug}.sql[/cyan] para a sua transformação"
266
+ )
267
+ console.print(
268
+ " 4. Publique o repositório e instale: "
269
+ "[dim]sidra-sql plugin install <git-url>[/dim]\n"
270
+ )
271
+ except FileExistsError as e:
272
+ console.print(f"[red]Erro:[/red] {e}")
273
+ raise typer.Exit(1) from e
274
+ except RuntimeError as e:
275
+ console.print(f"[red]Erro:[/red] {e}")
276
+ raise typer.Exit(1) from e
277
+
278
+
279
+ @plugin_app.command("add-pipeline")
280
+ def add_pipeline(
281
+ pipeline_id: str = typer.Argument(
282
+ ..., help="ID do pipeline (usado em 'sidra-sql run')"
283
+ ),
284
+ description: str = typer.Option(
285
+ "", "--description", "-d", help="Descrição do pipeline"
286
+ ),
287
+ path: str = typer.Option(
288
+ "",
289
+ "--path",
290
+ "-p",
291
+ help="Caminho do diretório relativo ao plugin (default: pipeline-id)",
292
+ ),
293
+ plugin_dir: Path = typer.Option(
294
+ Path("."),
295
+ "--plugin-dir",
296
+ help="Diretório raiz do plugin (default: diretório atual)",
297
+ ),
298
+ ):
299
+ """Adiciona um novo pipeline a um plugin existente."""
300
+ try:
301
+ adder = PipelineAdder(pipeline_id, description, path, plugin_dir)
302
+ adder.add()
303
+
304
+ console.print(
305
+ f"\n[bold green]Pipeline '{pipeline_id}' adicionado[/bold green]\n"
306
+ )
307
+ console.print(f" {adder.path}/")
308
+ console.print(" fetch.toml")
309
+ console.print(" transform.toml")
310
+ console.print(f" {adder.slug}.sql")
311
+ console.print(" manifest.toml [dim](atualizado)[/dim]\n")
312
+
313
+ console.print("[bold]Próximos passos:[/bold]")
314
+ console.print(
315
+ f" 1. Em [cyan]{adder.path}/fetch.toml[/cyan], substitua XXXX "
316
+ "pelo ID da tabela SIDRA"
317
+ )
318
+ console.print(
319
+ f" 2. Ajuste [cyan]{adder.path}/{adder.slug}.sql[/cyan] para "
320
+ "a sua transformação"
321
+ )
322
+ console.print(f" 3. Execute: [dim]sidra-sql run <alias> {pipeline_id}[/dim]\n")
323
+ except (FileNotFoundError, FileExistsError, ValueError) as e:
324
+ console.print(f"[red]Erro:[/red] {e}")
325
+ raise typer.Exit(1) from e
326
+ except Exception as e:
327
+ console.print(f"[red]Erro ao adicionar pipeline:[/red] {e}")
328
+ raise typer.Exit(1) from e
329
+
330
+
331
+ @plugin_app.command("validate")
332
+ def validate_plugin(
333
+ alias: str | None = typer.Argument(
334
+ None, help="Alias do plugin instalado (omitir para usar --plugin-dir)"
335
+ ),
336
+ plugin_dir: Path = typer.Option(
337
+ Path("."),
338
+ "--plugin-dir",
339
+ help="Diretório raiz do plugin (default: diretório atual)",
340
+ ),
341
+ ):
342
+ """Valida a estrutura e os arquivos de um plugin."""
343
+ if alias is not None:
344
+ target_dir = manager.registry.get_plugin_path(alias)
345
+ if not target_dir.exists():
346
+ console.print(f"[red]Erro:[/red] Plugin '{alias}' não encontrado.")
347
+ raise typer.Exit(1)
348
+ else:
349
+ target_dir = plugin_dir
350
+
351
+ console.print(f"\n[bold]Validando plugin em[/bold] {target_dir}\n")
352
+
353
+ report = PluginValidator(target_dir).validate()
354
+
355
+ severity_style = {
356
+ Severity.OK: "[green]OK[/green]",
357
+ Severity.WARN: "[yellow]AVISO[/yellow]",
358
+ Severity.ERROR: "[red]ERRO[/red]",
359
+ }
360
+
361
+ for section in report.sections:
362
+ console.print(f"[bold cyan]{section.title}[/bold cyan]")
363
+ for issue in section.issues:
364
+ tag = severity_style[issue.severity]
365
+ console.print(f" [{tag}] {issue.message}")
366
+ console.print()
367
+
368
+ if report.is_valid:
369
+ summary = "[bold green]Válido[/bold green]"
370
+ else:
371
+ summary = "[bold red]Inválido[/bold red]"
372
+
373
+ parts = [summary]
374
+ if report.error_count:
375
+ parts.append(f"[red]{report.error_count} erro(s)[/red]")
376
+ if report.warning_count:
377
+ parts.append(f"[yellow]{report.warning_count} aviso(s)[/yellow]")
378
+ if not report.error_count and not report.warning_count:
379
+ parts.append("sem erros ou avisos")
380
+
381
+ console.print("Resultado: " + ", ".join(parts) + "\n")
382
+
383
+ if not report.is_valid:
384
+ raise typer.Exit(1)
385
+
386
+
387
+ @plugin_app.command("list")
388
+ def list_plugins():
389
+ """List installed plugins and their pipelines."""
390
+ try:
391
+ pipelines = manager.list_pipelines()
392
+
393
+ table = Table(title="Installed Pipelines")
394
+ table.add_column("Plugin Alias", style="cyan")
395
+ table.add_column("Pipeline ID", style="magenta")
396
+ table.add_column("Description", style="green")
397
+
398
+ for alias, _plugin_name, pipeline in pipelines:
399
+ table.add_row(alias, pipeline.id, pipeline.description)
400
+
401
+ console.print(table)
402
+ except Exception as e:
403
+ console.print(f"[red]Error listing plugins:[/red] {e}")
404
+
405
+
406
+ @app.command("run")
407
+ def run_pipeline(
408
+ alias: str = typer.Argument(..., help="Plugin alias"),
409
+ pipeline_id: str | None = typer.Argument(
410
+ None, help="Pipeline ID to run (omit to run all)"
411
+ ),
412
+ force_metadata: bool = typer.Option(
413
+ False, "--force-metadata", help="Force refresh metadata"
414
+ ),
415
+ force_load: bool = typer.Option(
416
+ False,
417
+ "--force-load",
418
+ help="Recarregar arquivos já registrados em arquivo_carregado",
419
+ ),
420
+ ):
421
+ """Run pipeline(s) from an installed plugin. Omit pipeline_id to run all."""
422
+ try:
423
+ config = Config()
424
+
425
+ if pipeline_id is None:
426
+ manifest = manager.read_manifest(alias)
427
+ pipelines = manifest.pipelines
428
+ if not pipelines:
429
+ console.print(f"[yellow]No pipelines found in '{alias}'.[/yellow]")
430
+ return
431
+ _print_header()
432
+ for p in pipelines:
433
+ console.print(f"\n[cyan]→ {p.id}[/cyan]")
434
+ run_subtree(
435
+ config,
436
+ p.path,
437
+ force_metadata=force_metadata,
438
+ force_load=force_load,
439
+ console=console,
440
+ )
441
+ console.print(
442
+ "\n[bold green]All pipelines completed successfully![/bold green]"
443
+ )
444
+ else:
445
+ pipeline = manager.get_pipeline(alias, pipeline_id)
446
+
447
+ _print_header()
448
+ run_subtree(
449
+ config,
450
+ pipeline.path,
451
+ force_metadata=force_metadata,
452
+ force_load=force_load,
453
+ console=console,
454
+ )
455
+
456
+ console.print("[bold green]Pipeline completed successfully![/bold green]")
457
+
458
+ except ConfigError as e:
459
+ console.print(f"[bold yellow]{e}[/bold yellow]")
460
+ raise typer.Exit(1) from e
461
+ except Exception as e:
462
+ console.print(f"[bold red]Pipeline failed:[/bold red] {e}")
463
+ import traceback
464
+
465
+ traceback.print_exc()
466
+
467
+
468
+ @app.command("run-path")
469
+ def run_pipeline_path(
470
+ path: Path = typer.Argument(..., help="Path to the pipeline directory"),
471
+ force_metadata: bool = typer.Option(
472
+ False, "--force-metadata", help="Force refresh metadata"
473
+ ),
474
+ force_load: bool = typer.Option(
475
+ False,
476
+ "--force-load",
477
+ help="Recarregar arquivos já registrados em arquivo_carregado",
478
+ ),
479
+ ):
480
+ """Run a pipeline directly from a directory path, without a registered plugin."""
481
+ try:
482
+ resolved = path.resolve()
483
+ if not resolved.is_dir():
484
+ console.print(f"[bold red]Directory not found:[/bold red] {resolved}")
485
+ raise typer.Exit(1)
486
+
487
+ config = Config()
488
+ _print_header()
489
+ run_subtree(
490
+ config,
491
+ resolved,
492
+ force_metadata=force_metadata,
493
+ force_load=force_load,
494
+ console=console,
495
+ )
496
+ console.print("[bold green]Pipeline completed successfully![/bold green]")
497
+ except ConfigError as e:
498
+ console.print(f"[bold yellow]{e}[/bold yellow]")
499
+ raise typer.Exit(1) from e
500
+ except Exception as e:
501
+ console.print(f"[bold red]Pipeline failed:[/bold red] {e}")
502
+ import traceback
503
+
504
+ traceback.print_exc()
505
+ raise typer.Exit(1) from e
506
+
507
+
508
+ @app.command("transform")
509
+ def transform_pipeline(
510
+ alias: str = typer.Argument(..., help="Plugin alias"),
511
+ pipeline_id: str = typer.Argument(..., help="Pipeline ID to transform"),
512
+ ):
513
+ """Run only the transform step of a pipeline, without fetch or recursion."""
514
+ try:
515
+ config = Config()
516
+ pipeline = manager.get_pipeline(alias, pipeline_id)
517
+
518
+ transform_path = pipeline.path / "transform.toml"
519
+ if not transform_path.exists():
520
+ console.print(f"[red]No transform.toml found at {transform_path}[/red]")
521
+ raise typer.Exit(1)
522
+
523
+ console.print(f"[bold blue]Transforming {pipeline_id} from {alias}[/bold blue]")
524
+ TransformRunner(config, transform_path).run()
525
+ console.print("[bold green]Transform completed successfully![/bold green]")
526
+
527
+ except typer.Exit:
528
+ raise
529
+ except ConfigError as e:
530
+ console.print(f"[bold yellow]{e}[/bold yellow]")
531
+ raise typer.Exit(1) from e
532
+ except Exception as e:
533
+ console.print(f"[bold red]Transform failed:[/bold red] {e}")
534
+ import traceback
535
+
536
+ traceback.print_exc()
537
+
538
+
539
+ @app.command("export")
540
+ def export_pipeline(
541
+ alias: str = typer.Argument(..., help="Plugin alias"),
542
+ pipeline_id: str | None = typer.Argument(
543
+ None, help="Pipeline ID to export (omit to export all)"
544
+ ),
545
+ output_dir: Path = typer.Option(
546
+ Path("export"),
547
+ "--output-dir",
548
+ "-o",
549
+ help="Diretório de saída dos CSVs",
550
+ ),
551
+ as_of: str | None = typer.Option(
552
+ None,
553
+ "--as-of",
554
+ help="Snapshot do vintage publicado até a data (YYYY-MM-DD)",
555
+ ),
556
+ ):
557
+ """Export a pipeline's analytics outputs to CSV (optionally an as-of snapshot).
558
+
559
+ Without --as-of, dumps the materialized analytics tables. With --as-of DATE,
560
+ reconstructs each output as IBGE published it on that date, without touching
561
+ the persisted analytics tables.
562
+ """
563
+ try:
564
+ config = Config()
565
+
566
+ asof_date: dt.date | None = None
567
+ if as_of is not None:
568
+ try:
569
+ asof_date = dt.date.fromisoformat(as_of)
570
+ except ValueError as e:
571
+ console.print(
572
+ f"[bold red]Data inválida para --as-of:[/bold red] {as_of} "
573
+ "(use YYYY-MM-DD)"
574
+ )
575
+ raise typer.Exit(1) from e
576
+
577
+ if pipeline_id is None:
578
+ manifest = manager.read_manifest(alias)
579
+ pipelines = manifest.pipelines
580
+ if not pipelines:
581
+ console.print(f"[yellow]No pipelines found in '{alias}'.[/yellow]")
582
+ return
583
+ targets = [(p.id, p.path) for p in pipelines]
584
+ else:
585
+ pipeline = manager.get_pipeline(alias, pipeline_id)
586
+ targets = [(pipeline.id, pipeline.path)]
587
+
588
+ _print_header()
589
+ total = 0
590
+ for pid, path in targets:
591
+ toml_path = path / "transform.toml"
592
+ if not toml_path.exists():
593
+ console.print(
594
+ f"[yellow]Pipeline '{pid}' sem transform.toml — pulando.[/yellow]"
595
+ )
596
+ continue
597
+ console.print(f"\n[cyan]→ {pid}[/cyan]")
598
+ written = Exporter(config, toml_path, console=console).export(
599
+ output_dir, asof=asof_date
600
+ )
601
+ total += len(written)
602
+
603
+ console.print(
604
+ f"\n[bold green]Export concluído: {total} arquivo(s) em "
605
+ f"{output_dir}[/bold green]"
606
+ )
607
+
608
+ except typer.Exit:
609
+ raise
610
+ except ConfigError as e:
611
+ console.print(f"[bold yellow]{e}[/bold yellow]")
612
+ raise typer.Exit(1) from e
613
+ except Exception as e:
614
+ console.print(f"[bold red]Export failed:[/bold red] {e}")
615
+ import traceback
616
+
617
+ traceback.print_exc()
618
+ raise typer.Exit(1) from e
619
+
620
+
621
+ def main():
622
+ import sys
623
+
624
+ if hasattr(sys.stdout, "reconfigure"):
625
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
626
+ logging.basicConfig(level=logging.WARNING)
627
+ app()
628
+
629
+
630
+ if __name__ == "__main__":
631
+ main()